Um botão "Parar" que apenas encerra o spinner não cancela um agente. A chamada do modelo pode continuar, uma requisição HTTP pode ficar aberta e uma tool pode ter iniciado uma escrita antes de receber qualquer sinal.

A forma segura de interromper um agente é tratar cancelamento como contrato de runtime. Combine o sinal do chamador com um deadline da execução, passe o sinal para cada etapa e registre se a etapa foi cancelada antes ou depois de um possível efeito externo. A partir daí, o sistema decide entre encerrar, retomar ou pedir revisão.

Este post explica o padrão em TypeScript sem depender de um framework. A documentação do OpenAI Agents JS expõe signal, maxTurns e timeout por function tool, enquanto o AI SDK da Vercel expõe abortSignal e limites total, por etapa e por chunk. O código abaixo usa as mesmas ideias com APIs nativas do Node.js.

Diagrama mostra um agente passando por execução, deadline, cancelamento e retomada após uma tool travar.

Regra curta

  • O cancelamento do usuário e o deadline do servidor devem interromper o mesmo run.
  • Cada model call e tool call precisa receber o sinal combinado.
  • maxTurns impede loops longos, mas não substitui timeout nem cancelamento.
  • Depois de um efeito externo incerto, não repita a operação sem consultar uma chave idempotente ou o estado do recurso.

Cancelar, atingir timeout e falhar são a mesma coisa?

Não. Cancelamento é uma decisão externa para parar o run. Timeout é o vencimento de um orçamento. Falha é um erro que pode ou não permitir retry. Se o runtime transforma os três em Error("failed"), o agente perde a informação necessária para recuperar o trabalho.

O OpenAI Agents JS documenta signal para cancelar uma execução e maxTurns para limitar o loop. A mesma referência descreve MaxTurnsExceededError quando o limite é alcançado. Essas saídas devem virar estados diferentes, como cancelled, timed_out e failed, antes de chegar ao código que decide o retry.

Um deadline também não é apenas um setTimeout ao redor da Promise. Se o timer rejeita a função externa, mas não chega ao fetch, ao cliente MCP ou à consulta de banco, o processo que realmente consome tempo continua trabalhando. O usuário vê a resposta encerrada enquanto o servidor acumula trabalho invisível.

O primeiro passo, portanto, é definir a fronteira: um run tem um sinal de cancelamento, um orçamento total e um limite de turnos. Cada etapa pode ter um orçamento menor, mas nenhuma etapa pode ignorar o sinal do run.

Como combinar cancelamento do usuário e deadline?

Use um AbortSignal para cada motivo de interrupção e componha-os com AbortSignal.any(). O Node.js documenta AbortSignal.timeout() e AbortSignal.any() para criar um sinal que expira sozinho e outro que é abortado quando qualquer sinal de uma lista é encerrado.

O exemplo é ilustrativo. As funções callModel e executeTool representam adapters do seu provedor e da sua infraestrutura. O ponto testável é que o sinal do run chega a todas as operações.

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");
}

O limite 8 é uma política local, não um valor recomendado para todo agente. Escolha o limite observando a tarefa e registre a razão de parada. O OpenAI Agents JS usa maxTurns como limite de segurança, mas o runtime ainda precisa decidir o que fazer com a execução interrompida.

O sinal tem duas responsabilidades. Ele informa que novas operações não devem começar e oferece uma forma de interromper operações que já começaram. A segunda só funciona quando o adapter escuta o sinal.

Como propagar o sinal para cada tool call?

Passe o sinal até o ponto que espera I/O. No caso de fetch, use signal nas opções. No caso de um SDK de modelo ou de um cliente MCP, use a opção de abort suportada por esse cliente. Para uma função que executa trabalho local, verifique signal.aborted entre unidades que possam parar com segurança.

O guia de tools do OpenAI Agents JS documenta timeoutMs e informa que o timeout aborta details.signal. Isso só interrompe a função de verdade se a implementação utilizar o sinal. Um wrapper que ignora details.signal apenas transforma o resultado em erro enquanto continua ocupando recurso.

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}`);
  }
}

O AI SDK da Vercel descreve abortSignal e timeouts total, por etapa e por chunk em ToolLoopAgent. Essas camadas respondem a perguntas diferentes: o total limita o run, o step limita uma rodada e o chunk limita um fluxo que parou de produzir dados.

Não esconda o timeout dentro de cada tool. O orçamento precisa ser visível no run para evitar uma soma acidental de limites que ultrapasse o prazo da requisição. Uma etapa que recebeu apenas 200 ms restantes deve falhar rápido ou escolher uma alternativa menor, não começar uma chamada que já sabe que não terminará.

O que fazer quando a tool pode ter produzido um efeito?

Cancelar uma leitura é diferente de cancelar uma escrita. Se o sinal interrompeu um GET, normalmente o resultado é ausência de resposta. Se interrompeu uma cobrança, publicação, envio ou gravação, a operação pode ter chegado ao servidor antes de o cliente receber a confirmação.

Nesse caso, AbortError não prova que nada aconteceu. Marque a etapa como effect_uncertain, guarde uma chave idempotente e consulte o estado externo antes de repetir. Se o provedor não permite consulta, encaminhe para revisão ou use uma operação compensatória. Não deixe o modelo decidir sozinho que um timeout significa "tente de novo".

O post sobre execução durável para agentes de IA cobre onde persistir checkpoints e como retomar uma etapa. Aqui, o recorte é anterior: identificar se a etapa pode ser interrompida e que informação precisa sobreviver ao cancelamento.

Uma tabela de decisão simples ajuda:

Situação Estado registrado Próximo passo
Sinal recebido antes de iniciar a tool cancelled Encerrar o run sem retry automático.
Leitura cancelada sem efeito externo cancelled Repetir apenas se o chamador ainda quiser.
Escrita interrompida sem confirmação effect_uncertain Consultar por chave idempotente ou pedir revisão.
Orçamento total vencido entre duas etapas timed_out Persistir o estado e retomar com um novo orçamento.
Limite de turnos alcançado max_turns Registrar o loop e revisar a condição de parada.

Esse estado também combina com uma máquina de estados para agentes de IA. O evento cancel não deve ser uma mensagem solta no histórico. Ele precisa ser uma transição autorizada que interrompe trabalho novo e encaminha efeitos incertos para um caminho conhecido.

Como verificar o contrato de cancelamento?

Teste o cancelamento no meio de uma etapa lenta. Um teste que aborta antes de iniciar o agente só prova que o chamador consegue cancelar uma Promise. O teste útil inicia uma tool bloqueante, dispara o sinal e verifica que o adapter recebeu o mesmo sinal.

Uma fixture mínima pode usar uma Promise que termina apenas quando o sinal é abortado:

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 o documento", controller.signal);

setTimeout(() => controller.abort(new Error("user_clicked_stop")), 20);

await expect(run).rejects.toMatchObject({ reason: "user" });

Na suíte real, cubra também quatro casos: deadline do run, timeout de uma tool, max_turns e uma escrita que retorna effect_uncertain. Verifique que nenhuma etapa posterior começa depois do sinal e que o log contém run_id, tool_name, stop_reason e effect_status, sem guardar prompts ou segredos por padrão.

O guia de streaming do OpenAI Agents JS recomenda aguardar stream.completed depois de cancelar um stream. O mesmo princípio vale para um executor próprio: não marque o run como encerrado só porque o consumidor parou de ler eventos. Espere a limpeza do runtime e persista o estado que será necessário para uma retomada.

Como escolher entre parar e retomar?

Retome quando o estado persistido identifica o run, a etapa atual e os efeitos já confirmados. Pare de vez quando o usuário cancelou uma intenção que não deve continuar, quando o orçamento foi excedido sem uma rota de recuperação ou quando o estado externo não pode ser consultado com segurança.

O post sobre validação de tool calls em TypeScript ajuda na fronteira dos argumentos e resultados. Combine essa validação com a política de cancelamento: argumento inválido é erro recuperável, timeout de leitura pode ser retry limitado e efeito externo incerto exige consulta antes de qualquer repetição.

Em todos os casos, devolva uma saída honesta ao chamador. "Cancelado" significa que o runtime encerrou a execução, não que toda atividade externa desapareceu. "Retomar disponível" significa que existe checkpoint e chave suficiente para continuar. Essa distinção evita que a interface prometa uma limpeza que o sistema não consegue provar.

Perguntas frequentes

AbortSignal cancela qualquer tool automaticamente?

Não. O sinal só informa que a operação deve parar. A tool precisa repassá-lo ao fetch, SDK, cliente MCP ou rotina local que executa o trabalho. A documentação do OpenAI Agents JS explica que timeouts abortam details.signal, mas a implementação da função ainda precisa escutar esse sinal.

maxTurns substitui um timeout?

Não. maxTurns limita quantas rodadas do agente acontecem. Uma única tool pode ficar presa durante uma rodada, e uma requisição de modelo pode demorar antes do próximo turno. Use limite de turnos, deadline total e timeout por etapa como controles diferentes.

Posso repetir uma tool depois de AbortError?

Somente quando a operação é segura para repetir ou quando você consultou o efeito por uma chave idempotente. Um AbortError informa que a espera foi interrompida. Ele não garante que um servidor externo não recebeu ou aplicou a requisição antes do cancelamento.

Conclusão

Cancelamento seguro é uma propriedade do loop inteiro. O sinal precisa atravessar o modelo, cada tool e as chamadas downstream. O runtime precisa separar cancelled, timed_out, max_turns e effect_uncertain. A recuperação precisa saber o que já foi confirmado.

Comece com três testes: cancelar uma leitura lenta, vencer o deadline de uma etapa e interromper uma escrita antes da confirmação. Depois observe se o sistema consegue explicar o estado final sem adivinhar. Em fluxos longos de agentes, uso o RemoteCode como ferramenta do autor para manter continuidade entre sessões, mas ele não substitui sinais, deadlines ou idempotência.

Fontes consultadas

  • OpenAI Agents JS, "Running agents", consultado em 2026-08-13, https://openai.github.io/openai-agents-js/guides/running-agents/
  • OpenAI Agents JS, "Tools", consultado em 2026-08-13, https://openai.github.io/openai-agents-js/guides/tools/
  • OpenAI Agents JS, "Streaming", consultado em 2026-08-13, https://openai.github.io/openai-agents-js/guides/streaming/
  • Vercel AI SDK, "ToolLoopAgent", consultado em 2026-08-13, https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent
  • Node.js, "Globals", consultado em 2026-08-13, https://nodejs.org/dist/latest/docs/api/globals.html