Un botón "Detener" que solo cierra el spinner no cancela un agente. La petición del modelo puede continuar, una petición HTTP puede quedar abierta y una tool puede haber iniciado una escritura antes de recibir la señal.
La forma segura de interrumpir un agente es tratar la cancelación como un contrato del runtime. Combina la señal del cliente con un deadline del run, pasa esa señal a cada etapa y registra si la etapa se canceló antes o después de que pudiera comenzar un efecto externo. Así, el sistema decide si debe detenerse, reanudar o pedir revisión.
Este artículo explica el patrón en TypeScript sin depender de un framework. La documentación de OpenAI Agents JS expone signal, maxTurns y timeouts para function tools, mientras que el AI SDK de Vercel expone abortSignal y límites total, por etapa y por chunk. El código usa las mismas ideas con APIs nativas de Node.js.

Regla corta
- La cancelación del usuario y el deadline del servidor deben detener el mismo run.
- Cada llamada al modelo y cada tool call debe recibir la señal combinada.
maxTurnsevita loops largos, pero no sustituye al timeout ni a la cancelación.- Después de un efecto externo incierto, no repitas la operación sin una clave idempotente o una consulta del estado del recurso.
¿Cancelación, timeout y fallo son lo mismo?
No. La cancelación es una decisión externa para detener un run. Un timeout es un presupuesto que se agotó. Un fallo es un error que puede ser seguro o no para reintentar. Si el runtime convierte los tres en Error("failed"), pierde la información necesaria para recuperar el trabajo.
El runner de OpenAI Agents JS documenta signal para cancelar un run y maxTurns para limitar el loop. La misma referencia describe MaxTurnsExceededError cuando se alcanza el límite. Convierte estas salidas en estados distintos, como cancelled, timed_out y failed, antes de ejecutar la lógica de retry.
Un deadline tampoco es solo un setTimeout alrededor de una Promise. Si el timer rechaza la función externa, pero nunca llega a fetch, a un cliente MCP o a una consulta de base de datos, la operación que consume tiempo sigue trabajando. El usuario ve la respuesta cerrada mientras el servidor acumula trabajo invisible.
Empieza por definir la frontera: un run tiene una señal de cancelación, un presupuesto total y un límite de turnos. Cada etapa puede tener un presupuesto menor, pero ninguna puede ignorar la señal del run.
¿Cómo combinar la cancelación del usuario y el deadline?
Usa un AbortSignal para cada motivo de parada y combínalos con AbortSignal.any(). Node.js documenta AbortSignal.timeout() y AbortSignal.any() para crear una señal que expira sola y otra que se aborta cuando se detiene cualquiera de las señales de una lista.
El ejemplo es ilustrativo. callModel y executeTool representan adapters para tu proveedor y tu infraestructura. Lo que debes poder probar es que la señal del run llega a todas las operaciones.
type RunReason = "user" | "deadline" | "max_turns";
class RunStopped extends Error {
constructor(
readonly reason: RunReason,
readonly cause?: unknown,
) {
super(`run stopped: ${reason}`);
}
}
function runSignal(userSignal: AbortSignal, totalMs: number) {
const deadline = AbortSignal.timeout(totalMs);
return AbortSignal.any([userSignal, deadline]);
}
function reasonFor(signal: AbortSignal): RunReason {
return signal.reason?.name === "TimeoutError" ? "deadline" : "user";
}
async function runAgent(prompt: string, userSignal: AbortSignal) {
const signal = runSignal(userSignal, 30_000);
for (let turn = 0; turn < 8; turn += 1) {
if (signal.aborted) throw new RunStopped(reasonFor(signal), signal.reason);
const response = await callModel({ prompt, signal });
if (response.type === "final") return response.text;
const results = await Promise.all(
response.toolCalls.map((call) => executeTool(call, signal)),
);
prompt = appendToolResults(prompt, results);
}
throw new RunStopped("max_turns");
}
El límite 8 es una política local, no una recomendación para todos los agentes. Elígelo según la tarea y registra por qué terminó el run. El runner de OpenAI Agents JS usa maxTurns como límite de seguridad, pero el runtime todavía debe decidir qué hacer con la ejecución interrumpida.
La señal tiene dos funciones. Indica que no deben comenzar operaciones nuevas y ofrece una forma de interrumpir las que ya están en curso. La segunda solo funciona cuando el adapter escucha la señal.
¿Cómo propagar la señal a cada tool call?
Pasa la señal hasta el punto que espera I/O. Con fetch, usa signal en las opciones. Con un SDK de modelo o un cliente MCP, usa la opción de abort que ese cliente soporte. Para trabajo local, comprueba signal.aborted entre unidades que puedan detenerse de forma segura.
La guía de tools de OpenAI Agents JS documenta timeoutMs y explica que el timeout aborta details.signal. Eso solo detiene la función si su implementación usa la señal. Un wrapper que ignora details.signal convierte el resultado en un error, pero sigue consumiendo recursos.
async function executeTool(call: ToolCall, runSignal: AbortSignal) {
const stepSignal = AbortSignal.any([
runSignal,
AbortSignal.timeout(toolBudgetMs(call.name)),
]);
if (stepSignal.aborted) {
throw new RunStopped(reasonFor(stepSignal), stepSignal.reason);
}
switch (call.name) {
case "searchDocs":
return fetch("https://example.test/search", {
method: "POST",
body: JSON.stringify(call.input),
headers: { "content-type": "application/json" },
signal: stepSignal,
});
case "readMcpResource":
return mcpClient.readResource(call.input, { signal: stepSignal });
default:
throw new Error(`unknown tool: ${call.name}`);
}
}
El AI SDK de Vercel documenta abortSignal y timeouts total, por etapa y por chunk en ToolLoopAgent. Estas capas responden a preguntas diferentes: el total limita el run, el step limita una ronda y el chunk limita un stream que dejó de producir datos.
No escondas el timeout dentro de cada tool. El presupuesto debe ser visible en el run para que la suma de límites locales no supere por accidente el deadline de la petición. Una etapa a la que solo le quedan 200 ms debe fallar rápido o elegir un fallback menor, no comenzar una llamada que ya sabe que no puede terminar.
¿Qué hacer cuando una tool pudo producir un efecto?
Cancelar una lectura no es igual que cancelar una escritura. Si la señal interrumpe un GET, el resultado habitual es que no haya respuesta. Si interrumpe un cobro, una publicación, un mensaje o una escritura en la base de datos, la petición pudo llegar al servidor antes de que el cliente recibiera la confirmación.
En ese caso, AbortError no demuestra que no ocurrió nada. Marca la etapa como effect_uncertain, conserva una clave idempotente y consulta el estado externo antes de reintentar. Si el proveedor no permite consultarlo, envía el run a revisión o usa una operación compensatoria. No dejes que el modelo decida que un timeout significa "inténtalo otra vez".
El artículo sobre ejecución durable para agentes de IA explica dónde guardar checkpoints y cómo reanudar una etapa. Aquí el foco está en la frontera anterior: si la etapa se puede interrumpir y qué información debe sobrevivir a la cancelación.
| Situación | Estado registrado | Siguiente paso |
|---|---|---|
| La señal llegó antes de iniciar la tool | cancelled |
Terminar el run sin retry automático. |
| Se canceló una lectura sin efecto externo | cancelled |
Repetir solo si el usuario todavía lo quiere. |
| Una escritura se detuvo sin confirmación | effect_uncertain |
Consultar por clave idempotente o pedir revisión. |
| Se agotó el presupuesto total entre etapas | timed_out |
Persistir el estado y reanudar con un nuevo presupuesto. |
| Se alcanzó el límite de turnos | max_turns |
Registrar el loop y revisar la condición de parada. |
Este modelo también encaja con una máquina de estados para agentes de IA. Un evento cancel no debe ser un mensaje suelto en el historial. Debe ser una transición autorizada que detiene trabajo nuevo y envía los efectos inciertos a un camino conocido.
¿Cómo verificar el contrato de cancelación?
Prueba la cancelación en mitad de una etapa lenta. Un test que aborta antes de iniciar el agente solo demuestra que el llamador puede cancelar una Promise. Un test útil inicia una tool bloqueante, dispara la señal y comprueba que el adapter recibió la misma señal.
async function blockingTool(signal: AbortSignal) {
return new Promise<never>((_, reject) => {
if (signal.aborted) {
reject(signal.reason);
return;
}
const onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
});
}
const controller = new AbortController();
const run = runAgent("buscar el documento", controller.signal);
setTimeout(() => controller.abort(new Error("user_clicked_stop")), 20);
await expect(run).rejects.toMatchObject({ reason: "user" });
En la suite real, cubre el deadline del run, el timeout de una tool, max_turns y una escritura que pasa a effect_uncertain. Comprueba que ninguna etapa posterior comienza después de la señal y que el log contiene run_id, tool_name, stop_reason y effect_status, sin guardar prompts ni secretos por defecto.
La guía de streaming de OpenAI Agents JS recomienda esperar stream.completed después de cancelar un stream. El mismo principio vale para un executor propio: no marques el run como terminado solo porque el consumidor dejó de leer eventos. Espera la limpieza del runtime y persiste el estado necesario para reanudarlo.
¿Cómo elegir entre detener y reanudar?
Reanuda cuando el estado persistido identifica el run, la etapa actual y los efectos confirmados. Detén de forma definitiva cuando el usuario canceló una intención que no debe continuar, cuando se agotó el presupuesto sin una ruta de recuperación o cuando el estado externo no se puede consultar con seguridad.
El artículo sobre validación de tool calls en TypeScript cubre la frontera de argumentos y resultados. Combina esa validación con la política de cancelación: un argumento inválido es un error recuperable, un timeout de lectura puede permitir un retry limitado y un efecto externo incierto exige una consulta antes de repetir.
Devuelve siempre un resultado honesto al llamador. "Cancelado" significa que el runtime terminó la ejecución, no que desapareció toda operación externa. "Reanudación disponible" significa que existen un checkpoint y suficiente identidad para continuar. Esa diferencia evita que la interfaz prometa una limpieza que el sistema no puede demostrar.
Preguntas frecuentes
¿AbortSignal cancela automáticamente cualquier tool?
No. La señal solo indica que la operación debe detenerse. La tool debe pasarla a fetch, a un SDK, a un cliente MCP o a la rutina local que realiza el trabajo. La guía de tools de OpenAI Agents JS explica que los timeouts abortan details.signal, pero la implementación de la función todavía tiene que escucharla.
¿maxTurns sustituye a un timeout?
No. maxTurns limita cuántas rondas ejecuta el agente. Una tool puede quedar bloqueada durante una ronda y una petición al modelo puede tardar antes del siguiente turno. Usa límite de turnos, deadline total y timeout por etapa como controles separados.
¿Puedo reintentar una tool después de AbortError?
Solo cuando la operación sea segura de repetir o una clave idempotente permita consultar su efecto. AbortError indica que se interrumpió la espera. No garantiza que un servidor externo no recibiera o aplicara la petición antes de la cancelación.
Conclusión
La cancelación segura es una propiedad de todo el loop. La señal debe llegar al modelo, a cada tool y a las llamadas downstream. El runtime debe separar cancelled, timed_out, max_turns y effect_uncertain. La recuperación debe saber qué ya fue confirmado.
Empieza con tres pruebas: cancela una lectura lenta, deja vencer el deadline de una etapa e interrumpe una escritura antes de confirmarla. Después comprueba si el sistema puede explicar su estado final sin adivinar. En flujos largos de agentes, uso RemoteCode como herramienta del autor para mantener continuidad entre sesiones, pero no sustituye señales, deadlines ni idempotencia.
Fuentes consultadas
- OpenAI Agents JS, "Running agents", consultado el 13/08/2026, https://openai.github.io/openai-agents-js/guides/running-agents/
- OpenAI Agents JS, "Tools", consultado el 13/08/2026, https://openai.github.io/openai-agents-js/guides/tools/
- OpenAI Agents JS, "Streaming", consultado el 13/08/2026, https://openai.github.io/openai-agents-js/guides/streaming/
- Vercel AI SDK, "ToolLoopAgent", consultado el 13/08/2026, https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent
- Node.js, "Globals", consultado el 13/08/2026, https://nodejs.org/dist/latest/docs/api/globals.html