# Idempotência em tool calls: como evitar que o agente de IA execute a mesma ação duas vezes no Laravel

> Retry de job, tool repetida na mesma resposta e o "sim" enviado duas vezes podem fazer o agente agendar ou cobrar em dobro. Veja como blindar tool calls no Laravel.

Por Lucas Roberto · IA · publicado em 2026-10-23 · 11 min de leitura
Versão web: https://lucasroberto.com/blog/idempotencia-em-tool-calls-como-evitar-que-o-agente-de-ia-execute-a-mesma-acao-duas-vezes-no-laravel

Um agente de IA executa a mesma ação duas vezes por três motivos principais: o job que roda o loop do agente é reprocessado, o modelo pede a mesma tool duas vezes na mesma resposta, ou o usuário confirma em dobro. A defesa é tratar toda tool com efeito colateral como uma chamada a gateway de pagamento. Isso significa uma chave de idempotência derivada do contexto de negócio, um registro persistido de cada execução com índice único no PostgreSQL e um passo de confirmação atômico para ações sensíveis.

Aprendi isso do jeito chato. Quando você coloca um agente para agendar horários no WhatsApp ou gerar cobrança Pix, o problema deixa de ser "o modelo respondeu algo estranho" e vira "o cliente tem dois horários marcados" ou "o lojista recebeu duas cobranças". O LLM não sabe que já fez aquilo. Quem precisa saber é o seu back-end.

## Por que um agente de IA executa a mesma ação duas vezes?

Antes de pensar em solução, vale mapear de onde vem a duplicação. Nos agentes que rodo em Laravel com filas no Horizon, as causas típicas são estas:

1. **Retry do job do agente.** O job chama o Claude, recebe um `tool_use`, executa a tool (que cria o agendamento) e, antes de salvar o resultado ou responder ao usuário, estoura o timeout ou o worker morre. O Horizon reprocessa o job, o loop começa do zero, o modelo pede a mesma tool de novo e o agendamento é criado outra vez.
2. **Tool repetida na mesma resposta.** O Claude suporta *parallel tool use*, ou seja, pode devolver vários blocos `tool_use` numa única resposta. De vez em quando ele devolve duas chamadas praticamente idênticas, por exemplo quando o usuário pediu "marca pra mim e pra minha esposa" e o modelo se confundiu nos parâmetros.
3. **Confirmação duplicada do usuário.** O cliente manda "sim", acha que não foi e manda "sim" de novo. Ou o provedor de WhatsApp entrega o mesmo webhook duas vezes. Cada mensagem dispara um turno do agente, e cada turno pode executar a ação.
4. **O modelo esquece que já executou.** Em conversas longas, com memória resumida, o modelo perde o registro de que a tool já rodou e chama de novo quando o usuário pergunta "e aí, marcou?".

O item 3 tem uma parte que se resolve antes do agente: deduplicar o webhook pelo ID da mensagem e agrupar mensagens picadas. Mas nenhum desses filtros protege contra os itens 1, 2 e 4. A proteção tem que estar na execução da tool.

## Por que o tool_use_id não serve como chave de idempotência?

A primeira ideia é usar o `id` do bloco `tool_use` (algo como `toolu_01...`) como chave. Parece natural, porque é único por chamada. Só que ele é gerado pelo modelo a cada resposta. Quando o job é reprocessado e você chama a API de novo, o modelo devolve um `tool_use` com **outro** id, mesmo que os argumentos sejam idênticos. A chave muda exatamente no cenário em que você mais precisa que ela seja estável.

O `tool_use_id` continua útil para outra coisa: ligar o `tool_result` à chamada correspondente e rastrear a execução. Se você ainda não tem isso, o post sobre [como rastrear cada tool call de um agente de IA no Laravel](/blog/como-rastrear-um-agente-de-ia-no-laravel-trace-de-cada-tool-call-e-replay-da-conversa) mostra como montar esse trace. Para idempotência, a chave precisa vir de algo que você controla e que não muda entre tentativas.

## Como gerar uma chave de idempotência para tool calls?

Chave de idempotência é um identificador que representa "esta operação", de forma que repetir a operação com a mesma chave não produza um segundo efeito. Para tools de agente, eu uso duas fontes, dependendo da natureza da ação:

| Tipo de ação | Chave derivada de | Exemplo | Trade-off |
|---|---|---|---|
| Ação que só faz sentido uma vez | Chave de negócio (quem + o quê) | Agendar cliente X com profissional Y às 14h de terça | Bloqueia repetição legítima, mas aqui repetição nunca é legítima |
| Ação que pode se repetir legitimamente | Mensagem que disparou o turno + argumentos | Gerar cobrança Pix de R$ 50 | Protege retry e duplicata no mesmo turno, não protege pedido em turnos diferentes |

No agendamento, a chave de negócio resolve quase tudo. Ninguém quer o mesmo horário marcado duas vezes para a mesma pessoa, então tanto faz se a duplicata veio de retry, de tool paralela ou de esquecimento.

Na cobrança, a chave de negócio seria agressiva demais: o lojista pode querer, sim, duas cobranças de R$ 50 para o mesmo cliente. Então a chave inclui o ID da mensagem de entrada que iniciou o turno. Esse ID é estável entre retries do job, porque o job carrega a mesma mensagem. Para o caso de turnos diferentes, entra a confirmação explícita, que mostro mais abaixo.

Cada tool com efeito colateral implementa a própria chave:

```php
interface SideEffectTool
{
    public function name(): string;

    public function idempotencyKey(AgentTurn $turn, array $input): string;

    public function handle(array $input, string $idempotencyKey): array;
}

final class CreateBookingTool implements SideEffectTool
{
    public function name(): string
    {
        return 'create_booking';
    }

    public function idempotencyKey(AgentTurn $turn, array $input): string
    {
        return 'booking:' . hash('sha256', implode('|', [
            $turn->tenantId,
            $turn->customerId,
            (int) $input['professional_id'],
            Carbon::parse($input['starts_at'])->utc()->toIso8601String(),
        ]));
    }

    public function handle(array $input, string $idempotencyKey): array
    {
        // cria o agendamento e devolve dados para o modelo
    }
}
```

Repare na normalização. O modelo pode mandar `2025-03-11T14:00:00-03:00` numa tentativa e `2025-03-11 14:00` na outra. Se você fizer hash do input cru, a chave muda. Normalize datas para UTC, IDs para inteiro, valores monetários para centavos e strings com `trim` e `mb_strtolower` antes de compor a chave. Nunca confie que o LLM vai serializar os argumentos do mesmo jeito duas vezes.

E não dispense a constraint no domínio. Um índice único em `appointments (professional_id, starts_at)` continua sendo a última linha de defesa. A camada de idempotência da tool existe para devolver uma resposta coerente ao modelo em vez de uma exceção de violação de unicidade.

## Como guardar o resultado da tool para o retry do job?

A chave sozinha não basta. Quando a segunda tentativa chega, você precisa devolver ao modelo o **mesmo resultado** da primeira, senão ele vai dizer ao usuário que deu erro. Para isso uso uma tabela de execuções:

```php
Schema::create('agent_tool_executions', function (Blueprint $table) {
    $table->id();
    $table->string('idempotency_key')->unique();
    $table->foreignId('conversation_id')->constrained();
    $table->string('tool_name');
    $table->jsonb('input');
    $table->string('status'); // running, succeeded, failed
    $table->jsonb('result')->nullable();
    $table->timestamp('locked_until')->nullable();
    $table->timestamps();
});
```

O executor tenta reservar a chave com `insertOrIgnore`, que no PostgreSQL vira `INSERT ... ON CONFLICT DO NOTHING` e retorna quantas linhas foram inseridas. Se inseriu, quem executa é este processo. Se não inseriu, alguém já executou ou está executando:

```php
final class ToolExecutor
{
    public function run(SideEffectTool $tool, AgentTurn $turn, array $input): array
    {
        $key = $tool->idempotencyKey($turn, $input);

        $reserved = DB::table('agent_tool_executions')->insertOrIgnore([
            'idempotency_key' => $key,
            'conversation_id' => $turn->conversationId,
            'tool_name' => $tool->name(),
            'input' => json_encode($input),
            'status' => 'running',
            'locked_until' => now()->addMinutes(2),
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        if ($reserved === 0 && ! $this->takeOver($key)) {
            return $this->previousOutcome($key);
        }

        try {
            $result = $tool->handle($input, $key);
        } catch (Throwable $e) {
            DB::table('agent_tool_executions')->where('idempotency_key', $key)
                ->update(['status' => 'failed', 'updated_at' => now()]);

            throw $e;
        }

        DB::table('agent_tool_executions')->where('idempotency_key', $key)->update([
            'status' => 'succeeded',
            'result' => json_encode($result),
            'updated_at' => now(),
        ]);

        return $result;
    }

    private function takeOver(string $key): bool
    {
        // reassume execuções que falharam ou cujo worker morreu no meio
        return DB::table('agent_tool_executions')
            ->where('idempotency_key', $key)
            ->where(fn ($q) => $q->where('status', 'failed')
                ->orWhere(fn ($q) => $q->where('status', 'running')
                    ->where('locked_until', '<', now())))
            ->update([
                'status' => 'running',
                'locked_until' => now()->addMinutes(2),
                'updated_at' => now(),
            ]) === 1;
    }

    private function previousOutcome(string $key): array
    {
        $execution = DB::table('agent_tool_executions')
            ->where('idempotency_key', $key)->first();

        return match ($execution->status) {
            'succeeded' => json_decode($execution->result, true)
                + ['already_done' => true],
            default => [
                'already_in_progress' => true,
                'instruction' => 'Esta ação já está sendo processada. Não chame a tool de novo.',
            ],
        };
    }
}
```

Alguns detalhes que importam:

1. **O update condicional em `takeOver` é o lock.** Dois workers podem tentar reassumir ao mesmo tempo, mas só um recebe `1` como linhas afetadas. Nada de `SELECT` seguido de `UPDATE` sem condição.
2. **`locked_until` cobre o worker que morreu.** Sem ele, uma execução que ficou `running` para sempre trava a ação daquele cliente. Dimensione o prazo pelo timeout real da tool, com folga.
3. **O retorno com `already_done` é para o modelo.** Em vez de erro, ele recebe os dados do agendamento existente e uma flag. No system prompt, eu instruo: se a tool voltar com `already_done`, informe o que já foi feito e não repita. Funciona bem e evita a resposta "houve um problema, vou tentar de novo".
4. **A tabela só cobre tools com efeito colateral.** Consultar agenda, buscar pedido ou ver saldo não passa pelo executor. Registrar leitura é ruído e custo de escrita à toa.

Esse desenho conversa diretamente com a forma como o job do agente é configurado. Se você ainda está decidindo `tries`, `timeout` e `backoff`, o post sobre [chamar LLMs no Laravel com filas, retries e timeouts sem duplicar custo](/blog/como-chamar-llms-no-laravel-com-filas-retries-e-timeouts-sem-duplicar-custo) complementa este: lá o foco é não pagar duas vezes pelos tokens, aqui é não executar duas vezes a ação.

## O que fazer quando o Claude chama a mesma tool duas vezes na mesma resposta?

A tabela de execuções já resolve o caso de argumentos idênticos: a segunda chamada cai no `previousOutcome`. O problema é quando os argumentos diferem pouco e geram chaves diferentes, como dois horários vizinhos. Aí são duas ações legítimas para o sistema, e possivelmente um erro do modelo.

Para agentes que mexem com agenda ou dinheiro, eu desligo o parallel tool use. Na API de Messages da Anthropic isso é um campo dentro de `tool_choice`:

```json
{
  "model": "<modelo configurado>",
  "max_tokens": 1024,
  "system": "Você é o assistente de agendamento...",
  "tools": [ { "name": "create_booking", "description": "...", "input_schema": { "type": "object" } } ],
  "tool_choice": { "type": "auto", "disable_parallel_tool_use": true },
  "messages": [ ]
}
```

Com isso, o modelo chama no máximo uma tool por resposta. O trade-off é latência: um fluxo que precisaria de três consultas paralelas passa a fazer três idas e voltas. Por isso eu não desligo para tudo. Agentes de consulta, que só leem, ficam com paralelismo; agentes que escrevem ficam sem. Se o mesmo agente faz as duas coisas, prefiro desligar e aceitar a latência a lidar com duas escritas concorrentes no mesmo turno. Se você está começando a montar esse loop, o post sobre [como criar um agente de IA com tool calling do Claude no Laravel](/blog/como-criar-um-agente-de-ia-com-tool-calling-do-claude-no-laravel-para-agendamentos-no-whatsapp) mostra a estrutura de `tool_use` e `tool_result` que estou assumindo aqui.

## Como impedir ação duplicada quando o usuário confirma duas vezes?

Para ações sensíveis (cobrança, cancelamento, estorno), eu separo a tool em duas etapas. A primeira, `propose_charge`, não cobra nada. Ela cria uma ação pendente e devolve um resumo para o modelo apresentar ao usuário. A segunda, `confirm_action`, recebe o `action_id` e executa.

```php
Schema::create('pending_actions', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignId('conversation_id')->constrained();
    $table->string('type');
    $table->jsonb('payload');
    $table->string('status'); // pending, confirmed, expired
    $table->timestamp('expires_at');
    $table->timestamps();
});
```

A confirmação é uma transição de estado atômica:

```php
$confirmed = DB::table('pending_actions')
    ->where('id', $actionId)
    ->where('conversation_id', $turn->conversationId)
    ->where('status', 'pending')
    ->where('expires_at', '>', now())
    ->update(['status' => 'confirmed', 'updated_at' => now()]);

if ($confirmed === 0) {
    return ['already_confirmed_or_expired' => true];
}

// executa a cobrança usando o action_id como chave de idempotência
```

O segundo "sim" chega, o modelo chama `confirm_action` com o mesmo `action_id` e recebe `already_confirmed_or_expired`. Não há segunda cobrança. O filtro por `conversation_id` impede que um `action_id` vazado ou inventado pelo modelo confirme a ação de outra conversa, e o `expires_at` evita que um "sim" de amanhã confirme uma proposta de hoje.

Existe um passo além: tirar a confirmação das mãos do modelo. No WhatsApp, dá para mandar a proposta como mensagem interativa com botão, cujo payload carrega o `action_id`. Quando o webhook do clique chega, o seu código confirma direto, sem passar pelo LLM. É mais seguro, porque nenhum texto manipulado convence o modelo a confirmar sozinho, mas custa flexibilidade: o usuário que digita "pode mandar" em vez de clicar precisa de um caminho alternativo. Eu uso botão para dinheiro e confirmação via modelo para agenda.

## Como propagar a idempotência até o gateway de pagamento?

A tabela de execuções protege o seu lado. Mas se a tool chama um provedor como Asaas, Pagar.me ou Stripe e a requisição dá timeout, você não sabe se a cobrança foi criada. O `takeOver` vai reexecutar a tool, e sem cuidado o provedor cria a segunda cobrança.

Por isso o `handle` recebe a chave. Ela é repassada ao provedor como `Idempotency-Key` quando a API suporta, ou usada como `external_reference` para consultar antes de criar quando não suporta. Detalhei os dois caminhos no post sobre [timeout no gateway de pagamento e Idempotency-Key no Laravel](/blog/timeout-ao-chamar-o-gateway-de-pagamento-como-evitar-cobranca-duplicada-com-idempotency-key-no-laravel). A ideia é uma chave só atravessando todas as camadas: turno do agente, execução da tool, chamada ao provedor.

## Qual defesa resolve cada causa de duplicação?

| Causa | Defesa principal | Defesa complementar |
|---|---|---|
| Retry do job do agente | Tabela de execuções com chave estável | Idempotency-Key no provedor |
| Tool repetida na mesma resposta | `disable_parallel_tool_use` em agentes que escrevem | Chave de negócio |
| "Sim" duplicado / webhook duplicado | Ação pendente com transição atômica | Dedupe do webhook pelo ID da mensagem |
| Modelo esquece que executou | Chave de negócio + retorno `already_done` | Registrar ações executadas na memória do agente |
| Worker morre no meio | `locked_until` + `takeOver` condicional | Constraint única no domínio |

Para testar, simulo cada linha dessa tabela. Com `Http::fake` devolvo duas respostas seguidas do Claude com o mesmo `tool_use` e ids diferentes, rodo o job duas vezes e verifico que existe um agendamento só e que a segunda resposta ao usuário menciona o agendamento existente. Também testo o caso do `running` expirado, que é o mais fácil de esquecer.

## Conclusão: trate tool com efeito colateral como endpoint de pagamento

Se eu tivesse que reduzir tudo a uma regra, seria esta: o LLM é um cliente não confiável que vai repetir requisições, e você já sabe lidar com clientes assim. O caminho que eu sigo em todo agente novo:

1. Classificar as tools em leitura e escrita. Só as de escrita passam pelo executor idempotente.
2. Definir a chave de cada tool de escrita: chave de negócio quando a repetição nunca é legítima, mensagem do turno + argumentos normalizados quando pode ser.
3. Desligar parallel tool use no agente que escreve.
4. Colocar ações de dinheiro atrás de proposta + confirmação atômica, de preferência por botão.
5. Repassar a mesma chave ao provedor externo e manter a constraint única no domínio.

Nada disso depende do modelo acertar. E esse é o ponto. O prompt ajuda o agente a se comportar bem, mas quem garante que o cliente não vai receber duas cobranças é o PostgreSQL.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
