Saltar al contenido
23/30Capítulo 23 de 30

Construye un agent harness: el bucle y sus cinco salidas

Un bucle de quince líneas que funciona a la primera, roto siete veces a propósito, empezando por una fuga 77 veces más cara.

En esta página

Empieza por la parte honesta, porque nadie más lo dirá: «harness» es jerga, no un estándar. No hay especificación, ni comité, ni definición de referencia. Los cuatro artículos que cita este capítulo —ReAct,1 CoALA,2 SWE-bench y vLLM— no usan la palabra ni una sola vez en sus resúmenes. La implementación más descargada de esta cosa, el paquete ai de Vercel, con 89,4 millones de descargas al mes, tampoco la usa: la cadena harness aparece cero veces en los 397 KB de declaraciones de tipos distribuidas por la versión 7.0.93.3 El único lugar donde la palabra sostiene peso significa otra cosa por completo. SWE-bench dice «harness» cinco veces en su README, siempre como evaluation harness —el andamiaje contenerizado que aplica un parche y ejecuta las pruebas— y su módulo de Python es literalmente swebench.harness.run_evaluation.4

Así que dos cosas distintas comparten nombre. Un evaluation harness mantiene quieto al agent y lo puntúa. Un agent harness es el programa que ejecuta al agent: llama al modelo, ejecuta lo que el modelo pide, decide cuándo detenerse y mantiene el estado entre medias. Este capítulo construye el segundo, en menos de doscientas líneas de TypeScript, sin ningún framework.

El bucle en sí tiene quince líneas y funciona en el primer intento. Todo lo que viene después es una forma de salir de él.

Mostrar detalles

Qué necesita este capítulo de los anteriores.

  • Capítulo 14 para el cliente: plazos, triaje de estados, cancelación, claves de idempotencia y la técnica del proveedor simulado que se vuelve a usar aquí.
  • Capítulo 16 para la aritmética: los tokens de entrada crecen con el cuadrado de la conversación, y las tarifas usadas abajo son las que se leyeron allí el 6 de septiembre de 2026.
  • Capítulo 18 para el catálogo de herramientas: un schema que ve el modelo, un endpoint que nunca ve y la regla de que los errores son context en vez de excepciones.
  • Capítulo 22 para el bucle que este hereda y para las dos definiciones publicadas de «agent» que no están de acuerdo entre sí.

Aquí no hay tensores. Este es el segundo nodo de dependencias del curso: los capítulos 24, 25, 29 y 30 se ejecutan sobre el archivo de abajo, y del 26 al 28 construyen sobre lo que puede alcanzar.

El capítulo 14 no podía escribirse contra un proveedor real, porque no puedes pedirle un 429 en un momento elegido. Este capítulo tiene el mismo problema con otra forma: no puedes pedirle a un modelo real que se desboque, o que solicite la misma herramienta dos veces seguidas, bajo demanda y de forma reproducible.

Así que el primer programa es un proveedor guionizado: un endpoint con la forma de una API de chat completions cuya respuesta es una función del índice del turno y de lo que las herramientas hayan devuelto hasta ese momento. Cuenta tokens con un codificador byte-pair real, así que el dinero de abajo es aritmética, no decoración.

mock-provider.mjsJS
const SCRIPTS = {
  // A well-behaved task: list, read, answer.
  plan: (t) =>
    t === 0 ? asks(call("c1", "list_files", {}))
    : t === 1 ? asks(call("c2", "read_file", { path: "errors.log" }))
    : text("errors.log mentions a timeout: worker 7 timed out after 30000 ms."),

  // Never declares itself done.
  runaway: (t) => asks(call(`c${t}`, "list_files", {})),           

  // Guesses a file name, then corrects itself IF it was told what happened.
  recover: (t, all) =>
    t === 0 ? asks(call("c1", "read_file", { path: "timeout.log" }))
    : /Call list_files/.test(all)                                  
      ? (t === 1 ? asks(call("c2", "list_files", {}))
        : t === 2 ? asks(call("c3", "read_file", { path: "errors.log" }))
        : text("errors.log mentions a timeout."))
      : text("I could not read the file, so I do not know."),
};

const turn = messages.filter((m) => m.role === "assistant").length;             
const toolText = messages.filter((m) => m.role === "tool").map((m) => m.content).join("\n");
const message = SCRIPTS[scenario](turn, toolText);

Dos líneas sostienen el diseño. El índice del turno se deriva de la conversación, no se guarda en una variable, así que el proveedor no tiene estado y una ejecución puede matarse y reanudarse contra él. Y recover lee los resultados de las herramientas antes de decidir: un modelo guionizado que lee su propia transcripción es lo mínimo necesario para medir si el harness le dio algo que mereciera la pena leer.

El catálogo es el del capítulo 18, cuatro herramientas en tres archivos: list_files, read_file, delete_file —marcada como needsApproval— y scan_archive, que es lenta a propósito.

Esta es la idea completa, antes de cualquiera de las partes que la hacen sobrevivible.

loop.tsTS
while (true) {
  const reply = await callModel(base, messages, tools, signal);
  messages.push(reply.message);

  const calls = reply.message.tool_calls ?? [];
  if (!calls.length) return reply.message.content;          

  for (const c of calls) {
    const tool = byName.get(c.function.name);
    const result = await tool.run(JSON.parse(c.function.arguments));
    messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: result });
  }
}

Apúntalo al proveedor guionizado y hace exactamente lo que parece que hace:

TEXT
plan, cap 20    turns=3  tools=2  in=815  out=70  cost=$0.002470  ms=89  status=completed
   answer: "errors.log mentions a timeout: worker 7 timed out after 30000 ms."
   per-turn prompt tokens: 204, 269, 342

Tres turnos, dos ejecuciones de herramientas, un cuarto de céntimo de dólar estadounidense. Fíjate en la última línea: 204, 269, 342. Cada turno reenvía todo lo anterior, que es la factura cuadrática del capítulo 16 llegando a un lugar donde nadie ha escrito nada. El resto de este capítulo es lo que ocurre cuando esa línea no deja de crecer.

Apunta el mismo bucle al script runaway —un modelo que pide una herramienta en cada turno y nunca emite prosa— y el return marcado nunca se dispara. No hay otra salida. El programa se ejecuta hasta que muere el proceso o la tarjeta de crédito.

La corrección es una línea, es el primer control que recomienda la literatura,5 y todo el mundo acaba escribiéndola. Lo que casi nadie hace es medir cuánto vale:

límite de turnosllamadas al modelotokens de entradacoste
883.431$0.009070
202016.259$0.038038
505088.649$0.191098
100100337.299$0.702198

Lee juntas las dos últimas filas. Duplicar el límite de 50 a 100 no duplicó el coste; lo multiplicó por 3,7. Los tokens de entrada pasaron de 88.649 a 337.299, un factor de 3,8, porque el turno nn lleva consigo todos los turnos anteriores y el total es Θ(n2)\Theta(n^2). Un límite de turnos no es un dial lineal. Es un dial sobre la raíz cuadrada de tu peor caso, por eso subirlo de 20 a 100 «por si acaso» es una decisión que merece poner precio antes de tomarla.

Rotura dos: un límite de turnos no es un límite de dinero

Enlace a la sección: Rotura dos: un límite de turnos no es un límite de dinero

El problema de un límite de turnos es que un turno no tiene precio fijo. Veinte turnos sobre una transcripción corta cuestan $0.038 arriba. Veinte turnos con un catálogo de 200 herramientas, un conjunto de documentos recuperados y cuarenta mensajes de historial cuestan cientos de veces más, y el límite no lo sabe. Lo que el operador quiere acotar es la factura.

Así que el bucle cuenta dinero, usando el computeCost del capítulo 16 contra las tarifas leídas allí —$2.00 por millón de tokens de entrada y $12.00 por millón de salida, para el modelo tasado a lo largo de este curso:

harness.tsTS
const PRICE_IN = 2.0 / 1e6, PRICE_OUT = 12.0 / 1e6;
export const cost = (u: Usage) => u.prompt_tokens * PRICE_IN + u.completion_tokens * PRICE_OUT;

// at the top of every iteration, before asking the model anything:
if (state.turns >= opts.limits.maxTurns) return stop("max_turns_exceeded", { type: "max_turns" });
if (state.costUsd >= opts.limits.maxBudgetUsd) return stop("budget_exceeded", { type: "max_budget" }); 

// ...and once the reply is back, before anything else happens with it:
state.costUsd += cost(reply.usage);

El mismo script desbocado, sin límite de turnos en absoluto, tres presupuestos:

presupuestoturnos alcanzadosgasto real
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

Merece la pena nombrar dos cosas. Primero, el presupuesto compra un número distinto de turnos cada vez, que es precisamente el objetivo: acota lo que le importa al operador y deja que el recuento de turnos caiga donde lo sitúe la transcripción. Segundo, todas las filas se pasan. El presupuesto era $0.010 y se gastaron $0.010780, porque la comprobación se ejecuta antes de un turno y el precio de un turno no se conoce hasta que termina. No puedes acotar el gasto con exactitud; puedes acotarlo hasta dentro del coste de un turno. Dilo en la interfaz en vez de fingir, y pon la comprobación antes de la llamada para que el exceso sea de un turno y no de dos.

A estas alturas el bucle tiene tres salidas, y la forma del resto del capítulo ya se ve. Una ejecución de producción termina exactamente de una de cinco maneras, y no son variaciones unas de otras:

cómo terminaquién decidióqué debería hacer quien llama
el modelo dejó de pedirel modeloleer la respuesta
límite de turnostú, de antemanosubir el límite o aceptar un resultado parcial
presupuesto agotadotú, de antemanoaprobar más dinero o aceptar un resultado parcial
un error que no puedes reintentarel proveedor o una herramientaarreglar el despliegue; el triaje del capítulo 14 decide
intervino una personauna personaesperar un veredicto y luego reanudar

Colapsar estas opciones en un booleano es el error de diseño más común en este archivo, y es caro de una forma concreta: tres de las cinco son reanudables y dos no. Un agent que alcanzó su límite de turnos tiene una transcripción válida, un resultado parcial real y un siguiente paso; un agent que recibió un 401 no tiene nada de eso. Así que el harness registra el motivo como datos:

harness.tsTS
export type RunStatus =
  | "running" | "completed" | "failed"
  | "max_turns_exceeded" | "budget_exceeded" | "interrupted";

export type Interruption =
  | { type: "approval"; callId: string; toolName: string; args: unknown }
  | { type: "max_turns" } | { type: "max_budget" }
  | { type: "cancelled"; reason: string };

El capítulo 18 terminó con una afirmación sin número: devuelve el error de una herramienta al modelo como resultado de herramienta en vez de lanzarlo, y el modelo normalmente se corrige. Aquí está el número.

Un fallo, tres políticas. El modelo guionizado adivina un archivo que no existe; la herramienta lanza no such file: timeout.log. Call list_files to see what exists.

qué hace el harness con el errorturnosejecuciones de herramientacostequé obtuvo el usuario
lo lanza fuera del bucle11$0.000756un stack trace
devuelve Error: the tool failed.21$0.001462«No he podido leer el archivo, así que no lo sé.»
devuelve lo que ocurrió realmente43$0.003550«errors.log menciona un timeout.»

La tercera fila cuesta 4,7 veces la primera y es la única que responde a la pregunta. Y la segunda fila es la interesante, porque es lo que hacen en realidad la mayoría de codebases: se capturó el error, el bucle sobrevivió, al modelo se le dijo que algo había fallado pero no qué, y se rindió con educación. La diferencia entre las filas dos y tres no es gestión de errores. Es una frase escrita para un lector.

Por tanto, el harness trata una herramienta lanzada como datos y convierte la redacción en una política:

harness.tsTS
} catch (err: any) {
  if (signal.aborted) return stop("interrupted", { type: "cancelled", reason: String(signal.reason) });
  if (opts.toolErrorsAreFatal) { state.error = err.message; return stop("failed"); }
  result = (opts.toolErrorText ?? ((e: Error) => `Error: ${e.message}`))(err);   
}

El capítulo 18 también advirtió sobre el otro lado, y también tiene un precio. Apunta el bucle a una herramienta que falla por una razón que ningún mensaje puede arreglar —una lectura que el proceso no tiene permiso para realizar— y el modelo la reintenta para siempre:

TEXT
read a file the process may not open   turns=12  toolruns=11  in=7,079  cost=$0.018622
                                      status=max_turns_exceeded   answer=""

Once ejecuciones idénticas de una llamada que no puede tener éxito, 5,2 veces el coste de la ejecución que se recuperó de una arreglable, y nada al final. Los errores son context; un error permanente es context que envenena el resto de la ejecución. La distinción es el triaje de estados del capítulo 14 movido una capa hacia arriba: un error sobre el que el modelo puede actuar vuelve a la transcripción, y un error sobre el que no puede actuar debería detener la ejecución con un motivo. El límite de turnos es lo que hoy se interpone entre tú y el segundo caso, y eso es un suelo, no una corrección.

Ahora el fallo que la mayoría asume que no puede ocurrir. Los modelos se repiten. Pide a cualquier bucle que se ejecute el tiempo suficiente y verás la herramienta idéntica con los argumentos idénticos en dos turnos consecutivos.

Medido contra la referencia de la misma tarea sin repetición:

turnosejecuciones de herramientacoste
la tarea, sin repetición21$0.001396
la misma tarea, una llamada repetida32$0.002446
repetida, con caché de resultados en herramientas de solo lectura31$0.002446

La llamada duplicada costó $0.001050 extra, un aumento del 75 %, y esta es la parte que sorprende a la gente: cachear el resultado no recuperó nada. La deduplicación ahorró la ejecución de la herramienta, no el turno, porque cuando tu código detecta la repetición el modelo ya ha cobrado por pedirla. El ahorro es real cuando la herramienta es lenta, tiene rate limit o se factura por llamada; y es cero en la partida que creció.

Hay una versión peor. Aplica la misma caché a una herramienta que escribe y la segunda llamada silenciosamente no ocurre:

TEXT
naive cache on every tool        3 turns, 1 tool run,  files deleted: ["access.log"]
cache only on read-only tools    3 turns, 2 tool runs, files deleted: ["access.log","access.log"]

¿Cuál de esas opciones es correcta? Ninguna, de forma conocible. El protocolo dice que son dos llamadas: llevan dos valores tool_call_id distintos. Los argumentos dicen que podrían ser una. Un harness que decide comparando cadenas de argumentos algún día se tragará la segunda de dos cargas idénticas e intencionadas; y el capítulo 14 ya nombró el único mecanismo que resuelve esto de forma honesta, que es una clave de idempotencia generada por operación lógica por la capa que sabe qué es la operación. Hasta que la herramienta lleve una, el valor por defecto defendible es la puerta de solo lectura de arriba: cachea lecturas, ejecuta escrituras y deja que la propia idempotencia de la escritura se encargue del resto.

harness.tsTS
if (opts.dedupe && (tool.readOnly || opts.dedupeAll) && seen.has(signature)) {   
  state.messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: seen.get(signature)! });
  continue;
}

El script destructive lista los archivos y luego pide borrar uno que la tarea nunca mencionó. Nada en el bucle hasta ahora lo detendría.

Una herramienta marcada como needsApproval no falla y no continúa. Detiene la ejecución y devuelve el control, con todo lo que una persona necesita para decidir:

harness.tsTS
if (tool.needsApproval && !state.approved.includes(c.id)) {
  trace(state.runId, "approval_required", { toolName: tool.name, args: c.function.arguments, callId: c.id });
  return stop("interrupted", { type: "approval", callId: c.id, toolName: tool.name, args: JSON.parse(c.function.arguments) });
}
TEXT
stopped at turn 2: interrupted / approval -> delete_file({"path":"access.log"})
files deleted so far: []
approve -> total turns=3  deleted=["access.log"]  "Deleted access.log to free space."
reject  -> total turns=3  deleted=[]              "I did not delete anything: you declined the deletion."

Ese es todo el mecanismo, y la razón por la que es un return en vez de un callback es la siguiente sección: entre la parada y el veredicto, puede que el proceso ya no exista.

Pero antes, la medición que nadie espera. Un rechazo no es la ausencia de un resultado: la transcripción tiene un hueco identificado por tool_call_id y algo tiene que entrar en él. Ejecuta el mismo rechazo dos veces, cambiando solo lo que dice ese algo:

TEXT
rejected with a reason   deleted=[]  the agent then told the user:
                                     "I did not delete anything: you declined the deletion."
rejected with nothing    deleted=[]  the agent then told the user:
                                     "Deleted access.log to free space."

No se borró nada en ninguna de las dos ejecuciones, y en la segunda se le dice al usuario que sí. El sistema de permisos funcionó perfectamente; el informe es mentira. Es el mismo mecanismo que la tabla de errores de herramienta, llegando a un lugar que importa mucho más: una persona dijo que no, la acción se bloqueó correctamente, y el resumen del agent contradice la realidad porque la negativa nunca se escribió donde lee el modelo. La regla que sale de esto es corta: decida lo que decida tu código sobre una llamada a herramienta, escribe la decisión en la transcripción con palabras. El capítulo 30 vuelve a esto desde el lado de la seguridad, donde es la diferencia entre una pista de auditoría y ficción.

Una aprobación tarda minutos u horas. Un deploy tarda segundos. Si la ejecución vive en una variable local dentro de una petición HTTP, cada reinicio es una ejecución perdida y cada aprobación es una carrera.

Así que la ejecución no es un closure. Es un objeto plano serializable —mensajes, recuento de turnos, coste, estado, interrupción, la lista de ids de llamadas aprobadas— y el bucle es una función pura sobre él. Esa única restricción es lo que hace que la persistencia sea una preocupación de una línea:

harness.tsTS
export const save = (s: RunState, dir: string) => writeFileSync(`${dir}/${s.runId}.json`, JSON.stringify(s));
export const load = (dir: string, runId: string) => JSON.parse(readFileSync(`${dir}/${runId}.json`, "utf8"));

La pregunta de corrección no es guardar. Es qué ocurre al volver a entrar, y la respuesta ingenua te cobra dos veces. Si el proceso murió después de que el modelo pidiera una herramienta pero antes de que se escribiera el resultado, una reanudación que empieza llamando otra vez al modelo paga por un turno que ya tiene; y si empieza reejecutando las herramientas, realiza una escritura dos veces.

La corrección consiste en hacer que el bucle empiece preguntando a la transcripción qué queda pendiente:

harness.tsTS
export function pending(state: RunState): ToolCall[] {
  const answered = new Set(state.messages.filter((m) => m.role === "tool").map((m) => m.tool_call_id));
  const last = state.messages.at(-1);
  if (last?.role !== "assistant") return [];
  return (last.tool_calls ?? []).filter((c) => !answered.has(c.id));    
}

Cada iteración vacía primero pending y solo pregunta al modelo cuando no queda nada pendiente. Reanudar se convierte en la misma ruta de código que la normal, y lo mismo ocurre con la aprobación: una llamada aprobada es simplemente una llamada pendiente que ahora tiene permiso para ejecutarse. Mata el proceso a mitad de la tarea y reinícialo:

TEXT
process died after turn 2. tool runs so far: list_files, read_file:errors.log
restored from disk: turns=2  cost=$0.001570  messages=6  status=running
resumed and finished: turns=3  cost=$0.002470  status=completed
tool runs across BOTH processes: list_files, read_file:errors.log

Dos ejecuciones de herramientas en dos procesos para una tarea que necesita dos, y el coste final es idéntico al de la ejecución que nunca se cayó. El coste se acumula a través del reinicio porque estaba en el estado, no en una variable.

scan_archive tarda tres segundos aquí y representa a la herramienta que tarda tres minutos en producción. Mientras se ejecuta faltan dos cosas: el usuario no tiene ni idea de que algo está ocurriendo, y el botón Detener no hace nada.

Ambas tienen la misma corrección, y es el AbortSignal del capítulo 14 empujado un nivel más abajo. La signal no es solo para el fetch: se pasa a la herramienta, y una herramienta bien escrita la respeta:

harness.tsTS
result = await tool.run(JSON.parse(c.function.arguments), {
  signal,                                                                     
  progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});
TEXT
progress: scanned 200 of 1200 files  (t+506 ms)
progress: scanned 400 of 1200 files  (t+1007 ms)
no cancellation:            stopped after 3,015 ms, status=completed
user presses Stop at 1.2 s: stopped after 1,202 ms, status=interrupted, reason="user pressed Stop"

Dos milisegundos desde el clic hasta la parada, porque el sleep dentro de la herramienta escucha la misma signal que el fetch. Enróscala solo en fetch y el botón Detener idéntico espera tres segundos —la duración de la herramienta— y la ejecución «se cancela» después de que el trabajo que estaba cancelando ya haya terminado. La cancelación que no se cablea hasta el fondo es un spinner que dice la palabra correcta.

El harness emite una línea por evento, y el vocabulario es lo bastante pequeño como para memorizarlo: turn, tool_start, tool_progress, tool_result, approval_required, run_stopped.

TEXT
{"runId":"n1","type":"turn","turn":1,"prompt_tokens":204,"completion_tokens":23,"total_tokens":227,"costUsd":0.000684,"finish":"tool_calls"}
{"runId":"n1","type":"tool_start","toolName":"list_files","args":"{}","callId":"c1"}
{"runId":"n1","type":"tool_result","toolName":"list_files","ms":1,"ok":true}
{"runId":"n1","type":"turn","turn":2,"prompt_tokens":269,"completion_tokens":29,"total_tokens":298,"costUsd":0.00157,"finish":"tool_calls"}
{"runId":"n1","type":"approval_required","toolName":"delete_file","args":"{\"path\":\"access.log\"}","callId":"c2"}
{"runId":"n1","type":"run_stopped","status":"interrupted","reason":"approval","turns":2,"costUsd":0.00157}

Tres propiedades hacen que esto sea una trace y no logging. Cada línea lleva el run id, así que una ejecución que abarca tres procesos y dos días es una sola consulta. Cada línea turn lleva sus propios recuentos de tokens y el coste acumulado, así que «por qué esta ejecución costó cuarenta dólares» se puede responder a posteriori en vez de ser reproducible solo en teoría. Y run_stopped lleva el motivo, que es el campo que convierte un ticket de soporte en una respuesta de una línea: un agent que se detuvo por el presupuesto y un agent que se cayó se ven idénticos desde fuera y necesitan respuestas opuestas.

El capítulo 13 midió el tiempo hasta el primer token en hardware propio. El capítulo 14 lo midió a través de un socket. Un agent lo multiplica, y el multiplicador es un número que nadie eligió:

TrunN(tmodel+ttools)T_{\text{run}} \approx N \cdot \left( t_{\text{model}} + t_{\text{tools}} \right)

La misma tarea de tres turnos, cambiando solo la latencia del proveedor:

latencia del proveedor por turnoreloj real, 3 turnos
0 ms15 ms
200 ms615 ms
800 ms2.413 ms

El propio harness aporta quince milisegundos a una ejecución de tres turnos. Todo lo demás es NN multiplicado por un número que no controlas —establecido dentro de un serving scheduler que está batching tu petición con las peticiones de desconocidos6— y NN lo elige el modelo. Por eso el streaming del capítulo 14 importa más aquí que en un chat y ayuda menos: puedes emitir en streaming el turno final, y los cuatro turnos anteriores son silencio salvo que el harness emita progreso. También es todo el argumento a favor del evento tool_progress de arriba: en un agent, la unidad honesta de feedback no es el token, es el paso.

El mismo harness, con un modelo real detrás del puerto

Enlace a la sección: El mismo harness, con un modelo real detrás del puerto

Todo lo anterior se ejecutó contra un proveedor guionizado, lo que prueba el harness y no prueba nada sobre modelos. Así que cambia una línea —la costura del capítulo 14, LLM_BASE_URL— y apunta el código idéntico a un Qwen2.5-0.5B-Instruct local con las mismas cuatro herramientas. Seis tareas sobre los mismos tres archivos:

TEXT
turns=2 tools=1 wall= 15,260ms  Which file mentions a timeout?      -> "The file timeout.txt does not exist..."
turns=2 tools=1 wall= 13,037ms  How many files are in the directory? -> "There are three files..."
turns=2 tools=1 wall= 10,121ms  Read notes.txt and tell me what it says. -> "Remember to rotate your logs."
turns=2 tools=2 wall= 21,290ms  List the files and then read each one.
turns=2 tools=1 wall= 10,698ms  Which file is the largest?          -> "The largest file is access.log."
turns=2 tools=1 wall= 12,490ms  Is there a file about rotating logs?
TOTAL turns=12  toolruns=7  wall=82,896ms  mean turn=6,908ms

Tres hallazgos, y el tercero es la razón de que exista esta sección.

Todas y cada una de las tareas terminaron exactamente en dos turnos. El límite de turnos nunca se disparó, el presupuesto nunca se disparó, y la única salida del bucle fue que el modelo produjera prosa. Un modelo de quinientos millones de parámetros no itera; responde en su segunda respiración tenga o no tenga lo que necesita. El recuento de turnos es una propiedad del modelo, no de tu bucle.

El turno medio tardó 6.908 milisegundos, así que la tabla de latencia de arriba no es un juguete: a este tamaño, una ejecución hipotética de ocho turnos es casi un minuto de reloj real sin nada en pantalla.

Y las respuestas son incorrectas. El archivo más grande es errors.log; el modelo listó los archivos, nunca los leyó y aun así nombró uno. La primera tarea adivinó un nombre de archivo, se le dijo que no existía y concluyó. El harness se ejecutó impecablemente en las seis ejecuciones. Un harness hace que un agent sea gobernable, no correcto: el capítulo 29 trata de cómo averiguar cuál de las dos cosas es, y el capítulo 30 de lo que cuesta cuando nadie lo hizo.

Subagents, nombrados aquí y cobrados más tarde

Enlace a la sección: Subagents, nombrados aquí y cobrados más tarde

Una herramienta del catálogo puede tener otra ejecución detrás. La interfaz es la del capítulo 18 —un schema y un endpoint— y un agent entero cabe detrás porque esa interfaz es estrecha:

subagent.tsTS
const research: Tool = {
  name: "research",
  description: "Investigate one question and return a short summary.",
  parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"] },
  readOnly: true,
  async run(args, ctx) {
    const child = newRun(RESEARCH_SYSTEM, args.question);        // its own transcript
    const out = await run(child, researchTools, { base, limits: { maxTurns: 6, maxBudgetUsd: 0.05 }, signal: ctx.signal });
    return out.output ?? "no result";
  },
};

Tres cosas ya están bien en esas diez líneas y las tres son consecuencias de decisiones tomadas arriba: el hijo tiene su propia context window, así que la transcripción del padre recibe un resumen en vez de todo lo que leyó el hijo; tiene sus propios límites, así que un hijo desbocado no puede gastar el presupuesto del padre; y hereda la signal, así que un solo Stop cancela el árbol. Por qué una window limpia es el objetivo y no un efecto secundario es el capítulo 24; los cinco patrones de orquestación —prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser— y el handoff son el capítulo 25.

Dónde están los frameworks y por qué este curso no usó ninguno

Enlace a la sección: Dónde están los frameworks y por qué este curso no usó ninguno

Nada de lo anterior debería leerse como un argumento contra las librerías. Medido el 7 de septiembre de 2026, para el mes terminado el 29 de agosto:7

paquetedescargas ese mesqué te da
ai (Vercel AI SDK)89.385.860ToolLoopAgent, stopWhen, aprobación de herramientas, step hooks
@anthropic-ai/claude-agent-sdk41.558.352el Claude Code harness como librería: bucle, sesiones, hooks, permisos, subagents8
@langchain/langgraph12.812.815el bucle como grafo de estado explícito
langchain11.359.058cadenas, agents, integraciones
@openai/agents6.093.155agents, handoffs, guardrails
@mastra/core5.914.502agents, workflows, memoria

La razón por la que este curso escribe el bucle a mano en vez de enseñar uno de ellos se declara en vez de insinuarse, y es medible. En los doce meses hasta el 7 de septiembre de 2026, ai publicó 945 versiones y pasó de major 5 a major 7, y su clase de agent sigue exportándose como Experimental_Agent; langchain publicó 132 versiones en la misma ventana; @openai/agents publicó 83 y sigue en 0.x, quince meses después de su primer release.7 Un capítulo escrito contra cualquiera de esas APIs caduca en una estación, y este se publica en treinta y tres idiomas, así que cada reedición cuesta toda la traducción. Lo que hay debajo de todos ellos no se mueve: un bucle, una regla de parada, un catálogo, un ejecutor, algo de estado.

Y la implementación de referencia está de acuerdo con este capítulo en la parte que importa. En la versión 7.0.93 de ai, la salida del bucle no es un número: es stopWhen, una lista de predicados, de los cuales el recuento de pasos es solo uno:3

ai-sdk.tsTS
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>;   // exported as stepCountIs

La parada es plural en la implementación más usada de este bucle, por la misma razón por la que es plural en las ciento noventa y seis líneas de arriba.

Ahora tienes un harness: un bucle, un catálogo, un ejecutor, cinco salidas, una ejecución persistida, una signal que llega a las herramientas y una trace con un run id en cada línea. Los capítulos 24, 25, 29 y 30 construyen sobre este archivo, y del 26 al 28 sobre lo que puede alcanzar.

Le queda un problema, y las mediciones de arriba llevan todo el tiempo apuntándolo. Mira una vez más la tabla de la fuga: 3.431 tokens de entrada a ocho turnos, 337.299 a cien. Mira la ejecución que funciona: 204, 269, 342. Cada turno reenvía toda la transcripción, así que el context de un agent se llena con su propio historial; y el modelo usa peor el extremo lejano de una window larga que el cercano, por eso un buen agent en el turno cinco es uno confundido en el turno cuarenta.

Un límite de turnos no corrige eso. Solo impide que pagues por verlo ocurrir. Lo que lo corrige es decidir, en cada turno, qué tokens merecen la window: qué compactar, qué sacar a una nota que el agent pueda buscar, qué entregar a un subagent con una window limpia y qué definiciones de herramientas merecen su impuesto permanente. El capítulo 24 mide adónde va realmente la window, y la sorpresa es que no es a la conversación.


Todos los números de este capítulo salieron de los dos servidores descritos arriba, en Node 22 sobre una interfaz loopback: un proveedor guionizado que cuenta tokens con la codificación o200k_base, y Qwen/Qwen2.5-0.5B-Instruct detrás de un endpoint de la misma forma, greedy decoding, en CPU. Los costes se calculan a partir de recuentos de tokens medidos con las tarifas que leyó el capítulo 16 el 6 de septiembre de 2026 —$2.00 por millón de tokens de entrada y $12.00 por millón de salida— y ninguna petición de este capítulo fue a un endpoint de pago. Las respuestas del modelo local son respuestas de un modelo pequeño; léelas como evidencia sobre el bucle, que es idéntico en ambos casos, y no como un benchmark de lo que hacen los modelos actuales.

  1. Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. y Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). El entrelazado de trazas de razonamiento y acciones que implementa el bucle, y la fuente de la observación de que actuar permite a un modelo «gestionar excepciones», que es exactamente lo que mide la tabla de errores de herramienta de arriba.

  2. Sumers, T. R., Yao, S., Narasimhan, K. y Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). El tratamiento formal de lo que el bucle de arriba hace de manera informal: componentes de memoria modulares, un espacio de acción estructurado que abarca memoria interna y entornos externos, y «un proceso generalizado de toma de decisiones para elegir acciones». Léelo por el vocabulario que le falta al término de la industria; en particular, la separación entre memoria de trabajo, episódica, semántica y procedimental, cuya sombra práctica es la tabla de tres almacenes del capítulo 24.

  3. ai (Vercel AI SDK) versión 7.0.93, publicada el 4 de septiembre de 2026; declaraciones de tipos leídas desde cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts el 7 de septiembre de 2026. El archivo de 397 KB contiene cero ocurrencias de la cadena harness. La clase de agent es declare class ToolLoopAgent, exportada tanto como ToolLoopAgent como Experimental_Agent; declare function isStepCount(stepCount: number) —exportada como stepCountIs— se cita literalmente arriba; type StopCondition se muestra sin su segundo parámetro de tipo (RUNTIME_CONTEXT extends Context = Context), que es la única elisión en el extracto, igual que la forma de stopWhen?: Arrayable<StopCondition<...>> en generateText y streamText. El mismo archivo declara toolApproval, ToolApprovalStatus, prepareStep y repairToolCall, lo que equivale a decir que la implementación de referencia ha llegado de forma independiente a puertas de aprobación, preparación por paso y reparación de errores. 2

  4. Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. y Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). El resumen llama al artefacto «evaluation framework» de 2.294 problemas y nunca usa la palabra «harness»; el README del propio proyecto (github.com/SWE-bench/SWE-bench, leído el 7 de septiembre de 2026) la usa cinco veces, siempre como «evaluation harness», y el punto de entrada es python -m swebench.harness.run_evaluation. Ese es el otro sentido de la palabra: un andamiaje que mantiene quieto al agent y lo puntúa, no el bucle que lo ejecuta.

  5. Anthropic, Building effective agents, 19 de diciembre de 2024, anthropic.com/engineering/building-effective-agents, leído el 7 de septiembre de 2026. El modelo aumentado como bloque de construcción, el agent como un LLM «que usa herramientas basándose en feedback del entorno en un bucle», y la recomendación de condiciones de parada «como un número máximo de iteraciones» para mantener el control. El capítulo 22 cita su definición completa.

  6. Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. y Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). El otro bucle: el serving scheduler que agrupa tu petición con las peticiones de desconocidos y gestiona la KV cache del capítulo 13. Merece la pena saber que existe precisamente porque no es tuyo: la latencia que multiplica tu harness se fija dentro de él, y ningún trabajo sobre tu bucle la mueve.

  7. Recuentos de descargas del registro npm, api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, una ventana explícita en vez de la ventana deslizante last-month, e historiales de releases de registry.npmjs.org/<package>; ambos consultados el 7 de septiembre de 2026. Los recuentos de releases son el número de versiones publicadas en los doce meses hasta esa fecha, incluidas las compilaciones canary: ai 945 (última 7.0.93 el 04/09/2026, con las versiones major 5, 6 y 7 apareciendo todas dentro de la ventana), langchain 132 (última 1.5.10 el 20/08/2026), @openai/agents 83 (última 0.17.0 el 19/08/2026, primera publicación el 03/06/2025). 2

  8. Claude Agent SDK (@anthropic-ai/claude-agent-sdk) es el Claude Code harness empaquetado como librería —bucle de agent, herramientas integradas de archivos y shell, gestión de context, sesiones, hooks, permisos y subagents— documentado en code.claude.com/docs/en/agent-sdk. Es lo más parecido a una descripción publicada de cada mecanismo que este capítulo construye a mano, y merece la pena leerlo junto a tu propia implementación para las partes que nombra y que este capítulo solo señala.

¿Listo para dejar que elija LIA?

Crea con todos los modelos de IA en un mismo sitio. Empieza gratis hoy.