Si tu agente de IA repite la misma herramienta, bloquea primero las nuevas ejecuciones. Congela las herramientas que escriben, cobran, envían, despliegan o modifican datos; guarda la traza y el último estado válido; después cancela el worker desde el runtime. No intentes arreglar un bucle vivo añadiendo otro mensaje al prompt.
El incidente está contenido cuando la siguiente llamada insegura no se ejecuta, ningún efecto completado se repite, la ejecución termina con una razón consultable y el caso original pasa una prueba de replay dentro del presupuesto. Alcanzar max_iterations solo demuestra que saltó un techo: no demuestra recuperación, rollback ni idempotencia.

Protocolo de parada: seis acciones en orden
Un bucle (loop) de herramientas no es una llamada telefónica: es una secuencia en la que el modelo propone herramientas, recibe resultados y vuelve a actuar sin llegar a un estado terminal. Durante el incidente, sigue este orden:
- Pausa el dispatcher. El planner no puede crear nuevos tool calls, jobs ni subagentes.
- Cierra los efectos. Deshabilita temporalmente correo, pagos, CRM, deploys, escrituras en base de datos y cambios de archivos.
- Conserva la evidencia. Guarda
run_id, versión de estado, herramientas, argumentos canónicos, resultados, errores, timestamps y último progreso confirmado. - Cancela en la capa de ejecución. Termina runner, worker o proceso. «No vuelvas a llamar a la herramienta» puede orientar al modelo, pero no es un hard stop.
- Asigna una razón. Por ejemplo:
same_call_repeat,no_progress,fatal_tool_result,budget_exceededuoperator_stop. - Reconcilia el efecto real. Averigua si la última operación se confirmó aunque su respuesta se perdiera.
Si no sabes si la herramienta llegó a escribir, trátala como posiblemente completada. Antes de reintentar, consulta una clave de idempotencia o un registro durable de operación. Un segundo intento «por si acaso» es precisamente cómo un bucle técnico se convierte en duplicados de negocio.
¿Qué bucle tienes?
La solución depende de la trayectoria observada, no de la palabra «bucle».
| Síntoma en la traza | Diagnóstico probable | Evidencia decisiva | Acción segura |
|---|---|---|---|
| Misma herramienta, mismos argumentos y mismo resultado | Reintento idéntico | Huella estable y versión sin cambios | Bloquear antes de la próxima ejecución |
| Los argumentos varían, pero no aparece información nueva | Jitter sin progreso | Argumentos normalizados, clase de resultado y estado | Parar o exigir otra estrategia |
| A y B se alternan | Ciclo corto | Secuencia repetida sin avance | Romper ciclo y escalar |
| La herramienta devuelve éxito y vuelve a ejecutarse | Éxito no reconocido | Registro de operación y transición terminal ausente | Devolver resultado guardado, no repetir |
| Se repite un error de permiso, esquema o política | Fallo permanente | Misma clase de error y misma precondición | Terminar o pedir intervención |
| Timeout/429 con backoff acotado | Fallo potencialmente transitorio | Deadline, contador y precondición temporal distinta | Reintentar dentro de un presupuesto pequeño |
| Se repite el nombre, pero avanza un cursor | Repetición productiva | Cursor, versión o trabajo completado crece | Continuar bajo el cap global |
La huella exacta solo detecta el caso más fácil. Un agente puede reordenar claves JSON, cambiar un filtro irrelevante o alternar dos herramientas. Por eso necesitas dos señales separadas:
- identidad de acción: qué herramienta se propuso sobre qué estado;
- progreso verificable: qué cambió fuera del texto del modelo.
No uses «creo que avancé» como señal. Prefiere un cursor nuevo, una versión de fila, un test que pasa, un artefacto validado o un checkpoint durable.
Las cinco capas que no debes mezclar
Los frameworks ya reconocen que el runtime controla la terminación:
- La guía del runner de OpenAI Agents SDK explica que el bucle continúa tras tools y handoffs hasta una parada real.
- El middleware de LangChain separa límites de llamadas de modelo, límites de herramientas, retries e interrupción humana.
- Google ADK documenta máximo de iteraciones y salida explícita para LoopAgent.
Esas API son adaptadores útiles, pero cada capa responde una pregunta distinta:
| Capa | Responde | No garantiza |
|---|---|---|
| Hard cap | ¿Superó pasos, tiempo, tokens, llamadas o coste? | Que hubiera o no progreso |
| Detector de repetición | ¿Ya vimos esta acción o ciclo? | Que repetir fuera incorrecto |
| Detector de progreso | ¿Cambió estado externo o salida validada? | Que el efecto sea idempotente |
| Idempotencia | ¿La operación de negocio ya se confirmó? | Que la tarea completa haya terminado |
| Resultado terminal y recovery | ¿Éxito, retry, fallo, bloqueo o humano? | El techo global de recursos |
Configurar solo max_turns deja que el mismo efecto se ejecute varias veces antes del corte. Configurar solo un hash no ve ciclos A→B→A. Configurar solo instrucciones confía el límite a un sistema probabilístico.
La regla práctica es: el cap contiene; el progreso decide; la idempotencia protege; el estado terminal cierra; la política de recuperación elige el siguiente paso.
LoopGuard: una implementación transversal
Este LoopGuard en TypeScript se ejecuta antes y después de cada herramienta. progressVersion debe venir de estado durable o salida validada siempre que sea posible.
tstype ToolStatus = | "success" | "retryable_error" | "fatal_error" | "permission_denied" | "policy_blocked" | "needs_human"; type ToolCall = { tool: string; args: unknown; progressVersion: number; sideEffectKey?: string; }; type ToolResult = { status: ToolStatus; progressVersion: number; message: string; retryAfterMs?: number; }; type StopReason = | "step_budget" | "time_budget" | "same_call_repeat" | "no_progress" | "fatal_tool_result" | "needs_human"; function stable(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`; if (value && typeof value === "object") { const pairs = Object.entries(value as Record<string, unknown>) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`); return `{${pairs.join(",")}}`; } return JSON.stringify(value); } export class LoopGuard { private readonly startedAt = Date.now(); private readonly calls = new Map<string, number>(); private steps = 0; private noProgress = 0; private lastProgressVersion: number; constructor( private readonly limits = { maxSteps: 12, maxSameCallExecutions: 2, maxNoProgressExecutions: 3, maxWallMs: 60_000, }, initialProgressVersion = 0, ) { this.lastProgressVersion = initialProgressVersion; } before(call: ToolCall): StopReason | null { if (this.steps >= this.limits.maxSteps) return "step_budget"; if (Date.now() - this.startedAt >= this.limits.maxWallMs) { return "time_budget"; } const fingerprint = stable({ tool: call.tool, args: call.args, progressVersion: call.progressVersion, }); const previous = this.calls.get(fingerprint) ?? 0; if (previous >= this.limits.maxSameCallExecutions) { return "same_call_repeat"; } this.calls.set(fingerprint, previous + 1); this.steps += 1; return null; } after(result: ToolResult): StopReason | null { const terminal = [ "fatal_error", "permission_denied", "policy_blocked", ].includes(result.status); if (terminal) return "fatal_tool_result"; if (result.status === "needs_human") return "needs_human"; if (result.progressVersion > this.lastProgressVersion) { this.lastProgressVersion = result.progressVersion; this.noProgress = 0; } else { this.noProgress += 1; } return this.noProgress >= this.limits.maxNoProgressExecutions ? "no_progress" : null; } }
Los valores del ejemplo son límites de demostración, no una recomendación universal. Derívalos de trayectorias correctas: cuántos pasos necesita una tarea normal, qué latencia acepta el usuario y cuánto daño causaría un efecto duplicado.
Al reanudar, pasa la versión persistida del checkpoint como initialProgressVersion; de lo contrario, el primer resultado sin cambios puede parecer progreso nuevo. success confirma la operación de la herramienta, no siempre la tarea completa: el runner exterior debe hacer una transición validada o devolver la respuesta final. needs_human y las clases fatales detienen el runner de inmediato.
Antes de hacer hash:
- ordena claves;
- elimina defaults irrelevantes;
- conserva la versión de estado relevante;
- no metas secretos ni payloads privados en logs.
Para procesos paralelos o reinicios, almacena contadores, versiones y operaciones fuera de memoria. Un Map local desaparece con el worker y no evita una carrera.
El tool contract que permite terminar
Una herramienta no debería responder solo con texto ambiguo. Devuelve una clase terminal y la evidencia necesaria para decidir.
| Estado | ¿Reintentar? | Cambio obligatorio |
|---|---|---|
success | No repetir la misma operación | Finalizar o iniciar otra transición |
retryable_error | Sí, de forma acotada | Debe cambiar tiempo, salud, endpoint u otra precondición |
fatal_error | No | Corregir input, código o estrategia |
permission_denied | No automático | Una persona cambia autorización |
policy_blocked | No automático | Resolver con el owner de política |
needs_human | No | Reanudar solo con input humano registrado |
El caso público de n8n muestra una frontera útil: HubSpot respondió correctamente, pero el estado/instrucción seguía representando la tarea como incompleta y el agente repitió la herramienta hasta alcanzar el máximo. Aclarar el prompt mejoró ese caso; la garantía de no repetir el efecto sigue perteneciendo al runtime y a la herramienta.
Para un side effect, añade una clave estable:
tsasync function actualizarCliente(input: { customerId: string; operationId: string; patch: Record<string, unknown>; }): Promise<ToolResult> { const anterior = await operations.find(input.operationId); if (anterior?.status === "committed") { return { status: "success", progressVersion: anterior.version, message: "Operación ya confirmada; se devuelve el resultado guardado.", }; } const guardada = await operations.commitOnce(input); return { status: "success", progressVersion: guardada.version, message: "Actualización confirmada.", }; }
operationId identifica la acción de negocio, no el intento del modelo. Si dos workers quieren hacer lo mismo, ambos deben resolver al mismo registro durable.
Cómo recuperar una ejecución detenida
No vuelvas a enviar la misma conversación al mismo agente con contadores a cero. Eso no es recuperación; es una ruta de bypass.
Guarda un sobre de recuperación:
json{ "run_id": "run_123", "stop_reason": "no_progress", "last_good_progress_version": 4, "blocked_call": "search", "side_effect_state": "none_pending", "allowed_next_actions": ["change_strategy", "needs_human"] }
Después elige solo una salida diseñada:
- Finalizar: ya existe un éxito terminal; devuelve el resultado registrado.
- Reintentar: el error es transitorio, queda presupuesto y cambió una precondición.
- Cambiar estrategia: deshabilita la herramienta o usa una entrada validada distinta.
- Entregar resultado parcial: conserva lo útil y explica qué falta.
- Pedir intervención: faltan permisos, aprobación, contexto o una decisión destructiva.
- Cerrar: fallo permanente, presupuesto agotado o ninguna acción segura.
La nueva ejecución debe enlazar al run detenido y heredar los límites aplicables. No borres la evidencia para que «parezca un intento nuevo».
Cinco fixtures de primera mano
Probamos una simulación JavaScript transversal en el entorno Node.js del repositorio. No usó proveedor, red, credencial ni modelo de pago. El guard aplicó orden estable de argumentos, presupuesto total de pasos, máximo de dos ejecuciones por llamada idéntica, parada tras tres ejecuciones sin progreso, deadline, estados terminales y progressVersion monotónico.
| Fixture | Comportamiento simulado | Resultado observado |
|---|---|---|
success_after_one | Herramienta tipo CRM devuelve success a la primera | Completó tras una ejecución |
identical_retry | La misma search({"q":"same"}) devuelve retryable sin progreso | La tercera ejecución se bloqueó como same_call_repeat; solo hubo dos ejecuciones |
alternating_cycle | read y check se alternan sin avanzar | Se detuvo tras tres ejecuciones como no_progress |
changed_state_then_success | Avanzan cursor y versión de progreso | Permitió tres llamadas distintas y terminó en éxito |
fatal_no_retry | Una escritura devuelve permission_denied | Se detuvo tras una ejecución |
La prueba demuestra la ramificación determinista para esos cinco casos. No demuestra que un modelo etiquete siempre bien el progreso. En producción, deriva el avance de estado externo, validación o checkpoint durable.
El fixture productivo evita otro fallo: bloquear cualquier repetición del nombre de herramienta rompería paginación, polling y procesamiento por lotes. Repetir puede ser correcto si existe avance monotónico y el cap global sigue vigente.
Adapta el patrón sin ceder la responsabilidad
| Superficie | Úsala para | Mantén en tu aplicación |
|---|---|---|
| Runner y max-turn handling de OpenAI Agents SDK | Acotar runner y tratar su salida/error | Progreso, idempotencia, recuperación |
| Middleware de modelo/herramienta de LangChain | Techo por run, thread o tool | Finalización de negocio y operaciones |
| Límite y salida de Google ADK LoopAgent | Bucle de workflow acotado | Progreso de dominio y resume seguro |
| Bucle propio | Control total del dispatch | Guards, estado durable y pruebas |
Revisa nombres y defaults en la documentación oficial antes de fijarlos en configuración. El principio —la terminación se aplica fuera del prompt— es estable; las API concretas cambian.
Checklist de aceptación
Antes de reconectar herramientas reales:
- La siguiente llamada repetida se bloquea antes de ejecutarse.
-
successcierra la transición correspondiente. - Un error permanente no entra en retry.
- Un error transitorio solo reintenta con deadline y cambio relevante.
- Un ciclo de dos herramientas activa
no_progress. - La paginación válida demuestra avance externo.
- Una operación repetida devuelve el resultado durable.
- Reiniciar el worker no reinicia idempotencia ni presupuesto global.
- El run detenido muestra una razón y acciones permitidas.
- La traza no contiene credenciales ni datos sensibles en bruto.
Cuando el bucle de aplicación ya esté estable, protege también la ruta pagada. El kill switch de gasto API para agentes LLM explica cómo bloquear la próxima llamada al proveedor con una reserva atómica. El LoopGuard controla comportamiento; el spend gate controla gasto. Son controles complementarios.


