Para impedir que um agente de IA execute sozinho uma ação irreversível, a tool não deve executar nada. Ela grava uma ação pendente no banco. O sistema, e não o modelo, envia ao usuário um resumo com um código, e só uma resposta validada por código determinístico libera a execução num job idempotente. O LLM sugere a ação, o humano decide, e o Laravel garante que a decisão foi do humano.
Esse padrão se chama human-in-the-loop: um ponto do fluxo automatizado em que uma pessoa precisa aprovar antes de seguir. Em atendimento de fintech isso não é opcional. Estorno, link de cobrança, cancelamento de agendamento e alteração de dados bancários são ações em que um erro do modelo, ou uma prompt injection que manipula o agente, custa dinheiro real.
O que você vai ter funcionando no final
- Tools do agente classificadas por risco: as de leitura executam na hora, as de escrita viram pendência.
- Uma tabela
pending_actionsno PostgreSQL com código, expiração, limite de tentativas e no máximo uma pendência aberta por conversa. - Mensagem de confirmação montada pelo código a partir do banco, sem texto gerado pelo modelo.
- Interceptação da resposta "SIM 1234" antes de a mensagem chegar no LLM.
- Job que executa a ação uma única vez, revalida o estado e repassa uma chave de idempotência ao provedor.
- Testes com
Http::fakeprovando que nada é executado sem confirmação.
Pré-requisitos:
- Laravel 11 ou 12, PHP 8.2+.
- PostgreSQL (uso índice único parcial, que é recurso do PostgreSQL).
- Fila configurada (Redis com Horizon, ou o driver
database). - Chave da API da Anthropic. Os exemplos chamam a Messages API direto com o
Http::do Laravel, sem SDK. - Um agente com tool calling já funcionando. Se ainda não tem, comece pelo tutorial de agente com tool calling do Claude para agendamentos no WhatsApp.
Tool calling é o mecanismo em que o modelo, em vez de responder texto, devolve um bloco pedindo para o seu código executar uma função com determinados argumentos.
1. Quais ações do agente de IA precisam de confirmação?
Antes do código vem a classificação. Uso um critério simples: se a ação mexe em dinheiro, é difícil de desfazer ou afeta terceiros, ela pede confirmação.
| Tool | Efeito | Reversível? | Confirmação |
|---|---|---|---|
consultar_vendas |
Leitura | Não se aplica | Não |
consultar_saldo |
Leitura | Não se aplica | Não |
gerar_link_pagamento |
Cria cobrança para terceiro | Parcialmente | Sim |
estornar_venda |
Devolve dinheiro | Não | Sim |
cancelar_agendamento |
Libera horário, avisa profissional | Difícil | Sim |
A regra que sustenta o resto do tutorial é esta: o modelo nunca tem uma tool de "confirmar". Se existir uma tool confirmar_acao, um texto malicioso dentro de um PDF ou de uma mensagem encaminhada pode convencer o modelo a chamá-la. A confirmação acontece fora do LLM.
2. Criar a tabela de ações pendentes no PostgreSQL
php artisan make:migration create_pending_actions_table php artisan make:model PendingAction
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('pending_actions', function (Blueprint $table) { $table->id(); $table->foreignId('conversation_id')->constrained(); $table->foreignId('customer_id')->constrained(); $table->string('tool'); $table->jsonb('arguments'); $table->text('summary'); $table->string('code', 4); $table->unsignedSmallInteger('attempts')->default(0); $table->string('status')->default('pending'); $table->jsonb('result')->nullable(); $table->timestamp('expires_at'); $table->timestamp('confirmed_at')->nullable(); $table->timestamp('executed_at')->nullable(); $table->timestamps(); }); DB::statement("CREATE UNIQUE INDEX pending_actions_one_open_per_conversation ON pending_actions (conversation_id) WHERE status = 'pending'"); } public function down(): void { Schema::dropIfExists('pending_actions'); } };
O índice único parcial garante no banco que cada conversa tem no máximo uma pendência aberta. Isso elimina a ambiguidade de "SIM" se referir a qual ação, e não depende de o código da aplicação estar certo.
O enum de status:
<?php namespace App\Enums; enum PendingActionStatus: string { case Pending = 'pending'; case Confirmed = 'confirmed'; case Executing = 'executing'; case Executed = 'executed'; case Failed = 'failed'; case Expired = 'expired'; case Cancelled = 'cancelled'; }
O model:
<?php namespace App\Models; use App\Enums\PendingActionStatus; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class PendingAction extends Model { protected $guarded = []; protected function casts(): array { return [ 'arguments' => 'array', 'result' => 'array', 'status' => PendingActionStatus::class, 'expires_at' => 'datetime', 'confirmed_at' => 'datetime', 'executed_at' => 'datetime', ]; } public function customer(): BelongsTo { return $this->belongsTo(Customer::class); } public function conversation(): BelongsTo { return $this->belongsTo(Conversation::class); } }
3. Definir o contrato das tools e uma tool de risco
Cada tool declara se exige confirmação e separa três momentos: preparar (validar e normalizar), resumir (texto para o humano) e executar.
<?php namespace App\Agent\Tools; use App\Models\Customer; interface AgentTool { public function name(): string; public function definition(): array; public function requiresConfirmation(): bool; /** Valida e normaliza os argumentos. Lança ToolInputException se inválidos. */ public function prepare(array $input, Customer $customer): array; public function summarize(array $args, Customer $customer): string; public function execute(array $args, Customer $customer, string $idempotencyKey): array; }
A tool de estorno (RefundService e isRefundable() são do seu domínio):
<?php namespace App\Agent\Tools; use App\Models\Customer; use App\Services\RefundService; final class RefundSaleTool implements AgentTool { public function __construct(private RefundService $refunds) {} public function name(): string { return 'estornar_venda'; } public function definition(): array { return [ 'name' => $this->name(), 'description' => 'Solicita o estorno total de uma venda do cliente. NÃO executa na hora: o sistema envia um código e o cliente precisa confirmar.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'sale_id' => ['type' => 'integer', 'description' => 'ID da venda a estornar'], ], 'required' => ['sale_id'], ], ]; } public function requiresConfirmation(): bool { return true; } public function prepare(array $input, Customer $customer): array { $sale = $customer->sales()->whereKey($input['sale_id'] ?? 0)->first(); if (! $sale || ! $sale->isRefundable()) { throw new ToolInputException('Venda não encontrada ou não pode ser estornada.'); } return ['sale_id' => $sale->id, 'amount_cents' => $sale->amount_cents]; } public function summarize(array $args, Customer $customer): string { $sale = $customer->sales()->findOrFail($args['sale_id']); $valor = number_format($args['amount_cents'] / 100, 2, ',', '.'); return "Estornar a venda #{$sale->id} de R$ {$valor} feita em {$sale->created_at->format('d/m/Y H:i')}"; } public function execute(array $args, Customer $customer, string $idempotencyKey): array { $sale = $customer->sales()->findOrFail($args['sale_id']); if (! $sale->isRefundable() || $sale->amount_cents !== $args['amount_cents']) { throw new ToolInputException('A venda mudou desde o pedido de confirmação.'); } $refund = $this->refunds->refund($sale, $idempotencyKey); return ['refund_id' => $refund->id, 'status' => $refund->status]; } }
Dois detalhes importam. Primeiro, prepare busca a venda pelo cliente da conversa, então o modelo não consegue estornar venda de outro lojista nem passando outro ID. Segundo, o valor gravado na pendência é o do banco, não o que o modelo disse. O resumo mostrado ao humano é montado a partir desses dados.
4. Transformar a chamada da tool em pendência
O dispatcher é o ponto onde o fluxo se divide. Tool de leitura executa na hora. Tool de risco cria a pendência, manda a mensagem de confirmação direto pelo WhatsApp e devolve ao modelo um tool_result deixando claro que nada aconteceu ainda.
<?php namespace App\Agent; use App\Agent\Tools\ToolInputException; use App\Enums\PendingActionStatus; use App\Models\Conversation; use App\Models\PendingAction; use App\Services\WhatsAppClient; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; final class ToolDispatcher { public function __construct( private ToolRegistry $registry, private WhatsAppClient $whatsapp, ) {} public function handle(Conversation $conversation, string $name, array $input): array { $tool = $this->registry->get($name); $customer = $conversation->customer; try { $args = $tool->prepare($input, $customer); if (! $tool->requiresConfirmation()) { return $tool->execute($args, $customer, (string) Str::uuid()); } } catch (ToolInputException $e) { return ['error' => $e->getMessage()]; } $pending = DB::transaction(function () use ($conversation, $customer, $tool, $args) { PendingAction::where('conversation_id', $conversation->id) ->where('status', PendingActionStatus::Pending->value) ->update(['status' => PendingActionStatus::Cancelled->value]); return PendingAction::create([ 'conversation_id' => $conversation->id, 'customer_id' => $customer->id, 'tool' => $tool->name(), 'arguments' => $args, 'summary' => $tool->summarize($args, $customer), 'code' => (string) random_int(1000, 9999), 'status' => PendingActionStatus::Pending, 'expires_at' => now()->addMinutes(10), ]); }); $this->whatsapp->sendText( $conversation->phone, "Confirma esta operação?\n\n{$pending->summary}\n\nResponda SIM {$pending->code} para confirmar ou NÃO para cancelar. O pedido expira em 10 minutos." ); return [ 'status' => 'aguardando_confirmacao', 'instrucao' => 'A ação ainda NÃO foi executada. O sistema enviou ao cliente um resumo com código. Não afirme que a operação foi feita.', ]; } }
A mensagem de confirmação sai pelo WhatsAppClient e não pela resposta do modelo. Se ela fosse gerada pelo LLM, um resumo errado ("estornar R$ 8,90" quando o valor é R$ 89,00) levaria o cliente a confirmar algo diferente do que vai ser executado.
5. Ligar o dispatcher ao loop do agente com a API do Claude
O loop é o de sempre do tool calling. Mostro completo porque tem dois ajustes que fazem diferença aqui.
<?php namespace App\Agent; use App\Models\Conversation; use Illuminate\Support\Facades\Http; final class AgentTurn { public function __construct( private ToolDispatcher $dispatcher, private ToolRegistry $registry, ) {} public function run(Conversation $conversation, array $messages): string { for ($i = 0; $i < 5; $i++) { $response = Http::withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => '2023-06-01', ])->timeout(60)->post('https://api.anthropic.com/v1/messages', [ 'model' => config('services.anthropic.model'), 'max_tokens' => 1024, 'system' => 'Você é o assistente de atendimento. Ações como estorno e cobrança só acontecem depois que o cliente confirma com o código enviado pelo sistema. Nunca diga que uma ação foi feita se o resultado da ferramenta for aguardando_confirmacao.', 'tools' => $this->registry->definitions(), 'tool_choice' => ['type' => 'auto', 'disable_parallel_tool_use' => true], 'messages' => $messages, ])->throw()->json(); $content = array_map(function (array $block) { if ($block['type'] === 'tool_use') { $block['input'] = (object) $block['input']; } return $block; }, $response['content']); $messages[] = ['role' => 'assistant', 'content' => $content]; if ($response['stop_reason'] !== 'tool_use') { return collect($response['content'])->where('type', 'text')->pluck('text')->implode("\n"); } $results = []; foreach ($response['content'] as $block) { if ($block['type'] !== 'tool_use') { continue; } $output = $this->dispatcher->handle($conversation, $block['name'], $block['input']); $results[] = [ 'type' => 'tool_result', 'tool_use_id' => $block['id'], 'content' => json_encode($output, JSON_UNESCAPED_UNICODE), ]; } $messages[] = ['role' => 'user', 'content' => $results]; } return 'Não consegui concluir agora. Vou te passar para um atendente.'; } }
No config/services.php:
'anthropic' => [ 'key' => env('ANTHROPIC_API_KEY'), 'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-5'), ],
O disable_parallel_tool_use impede o modelo de pedir dois estornos na mesma resposta. O cast (object) está explicado em "Erros comuns".
6. Como validar o "SIM" do cliente sem passar pelo LLM?
A resposta de confirmação é tratada por código comum, antes de qualquer chamada ao modelo. Se não houver pendência aberta, ou se a mensagem não for uma confirmação ou um cancelamento, ela segue para o agente normalmente.
<?php namespace App\Agent; use App\Enums\PendingActionStatus; use App\Jobs\ExecutePendingAction; use App\Models\Conversation; use App\Models\PendingAction; use App\Services\WhatsAppClient; use Illuminate\Support\Str; final class ConfirmationHandler { public function __construct(private WhatsAppClient $whatsapp) {} public function tryHandle(Conversation $conversation, string $text): bool { $pending = PendingAction::where('conversation_id', $conversation->id) ->where('status', PendingActionStatus::Pending->value) ->first(); if (! $pending) { return false; } $normalized = Str::of($text)->squish()->upper()->toString(); if (in_array($normalized, ['NAO', 'NÃO', 'CANCELAR'], true)) { $pending->update(['status' => PendingActionStatus::Cancelled]); $this->whatsapp->sendText($conversation->phone, 'Cancelado. Nada foi alterado.'); return true; } if (! preg_match('/^SIM\s+(\d{4})$/u', $normalized, $m)) { return false; } if ($pending->expires_at->isPast()) { $pending->update(['status' => PendingActionStatus::Expired]); $this->whatsapp->sendText($conversation->phone, 'Esse pedido expirou. Se ainda quiser, é só pedir de novo.'); return true; } if (! hash_equals($pending->code, $m[1])) { $pending->increment('attempts'); if ($pending->attempts >= 3) { $pending->update(['status' => PendingActionStatus::Cancelled]); $this->whatsapp->sendText($conversation->phone, 'Código incorreto três vezes. Cancelei o pedido por segurança.'); } else { $this->whatsapp->sendText($conversation->phone, 'O código não confere. Confira a mensagem de confirmação.'); } return true; } $claimed = PendingAction::whereKey($pending->id) ->where('status', PendingActionStatus::Pending->value) ->update([ 'status' => PendingActionStatus::Confirmed->value, 'confirmed_at' => now(), ]); if ($claimed === 1) { ExecutePendingAction::dispatch($pending->id); $this->whatsapp->sendText($conversation->phone, 'Confirmado. Estou processando e te aviso o resultado.'); } return true; } }
No controller do webhook do WhatsApp, a interceptação vem primeiro:
public function __invoke(Request $request, ConfirmationHandler $confirmations) { [$conversation, $text] = $this->parseIncoming($request); if ($confirmations->tryHandle($conversation, $text)) { return response()->noContent(); } ProcessAgentTurn::dispatch($conversation->id, $text); return response()->noContent(); }
Por que código e não só "SIM"? Porque "sim" aparece em qualquer conversa ("sim, mas antes me diz o saldo"). O código de 4 dígitos amarra a resposta a uma pendência específica e exige um ato deliberado. O UPDATE ... WHERE status = 'pending' atômico garante que duas mensagens "SIM 1234" chegando juntas disparem só um job.
7. Executar a ação confirmada num job idempotente
Idempotência é a propriedade de uma operação produzir o mesmo efeito se for executada uma ou várias vezes. O job usa o status executing como trava e repassa ao provedor uma chave estável derivada do ID da pendência.
php artisan make:job ExecutePendingAction
<?php namespace App\Jobs; use App\Agent\ToolRegistry; use App\Enums\PendingActionStatus; use App\Models\PendingAction; use App\Services\WhatsAppClient; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Throwable; class ExecutePendingAction implements ShouldQueue { use Queueable; public int $tries = 1; public function __construct(public int $pendingActionId) {} public function handle(ToolRegistry $registry, WhatsAppClient $whatsapp): void { $claimed = PendingAction::whereKey($this->pendingActionId) ->where('status', PendingActionStatus::Confirmed->value) ->update(['status' => PendingActionStatus::Executing->value]); if ($claimed !== 1) { return; } $action = PendingAction::with(['customer', 'conversation'])->findOrFail($this->pendingActionId); $tool = $registry->get($action->tool); try { $result = $tool->execute($action->arguments, $action->customer, "pending-action-{$action->id}"); $action->update([ 'status' => PendingActionStatus::Executed, 'result' => $result, 'executed_at' => now(), ]); $whatsapp->sendText($action->conversation->phone, "Feito: {$action->summary}."); } catch (Throwable $e) { $action->update([ 'status' => PendingActionStatus::Failed, 'result' => ['error' => $e->getMessage()], ]); report($e); $whatsapp->sendText($action->conversation->phone, 'Não consegui concluir a operação agora. Um atendente vai verificar e te retornar.'); } } }
Repare na mensagem de falha: ela não diz "nada foi alterado". Num timeout com o gateway você não sabe se o estorno aconteceu. Por isso a chave pending-action-{id} vai para o provedor, e o mesmo raciocínio do post sobre timeout no gateway e Idempotency-Key vale aqui: a nova tentativa, manual ou por reconciliação, usa a mesma chave e não duplica.
Depois de executar, registre no histórico da conversa uma mensagem do tipo "[sistema] Ação executada: ..." para o agente saber, no próximo turno, que o estorno já foi feito.
8. Expirar pendências antigas com o scheduler
O ConfirmationHandler já trata pendência vencida no momento da resposta, mas uma pendência aberta para sempre trava o índice único da conversa. Em routes/console.php:
<?php use App\Enums\PendingActionStatus; use App\Models\PendingAction; use Illuminate\Support\Facades\Schedule; Schedule::call(function () { PendingAction::where('status', PendingActionStatus::Pending->value) ->where('expires_at', '<', now()) ->update(['status' => PendingActionStatus::Expired->value]); })->everyMinute()->name('expire-pending-actions')->withoutOverlapping();
Aproveite o mesmo agendamento para alertar sobre pendências em executing há mais de alguns minutos. É o sinal de worker que morreu no meio da execução.
9. Como testar que o agente não executa nada sem confirmação?
O teste mais importante simula o modelo pedindo o estorno e verifica que o estado da venda não mudou até a confirmação.
it('só estorna depois do SIM com código', function () { Queue::fake(); $conversation = Conversation::factory()->create(); $sale = Sale::factory()->for($conversation->customer)->refundable()->create(); Http::fake([ 'api.anthropic.com/*' => Http::sequence() ->push([ 'stop_reason' => 'tool_use', 'content' => [[ 'type' => 'tool_use', 'id' => 'toolu_1', 'name' => 'estornar_venda', 'input' => ['sale_id' => $sale->id], ]], ]) ->push([ 'stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'Enviei um código para você confirmar.']], ]), '*' => Http::response([], 200), ]); app(AgentTurn::class)->run($conversation, [['role' => 'user', 'content' => 'estorna a última venda']]); $pending = PendingAction::sole(); expect($pending->status)->toBe(PendingActionStatus::Pending); expect($sale->fresh()->isRefundable())->toBeTrue(); Queue::assertNothingPushed(); app(ConfirmationHandler::class)->tryHandle($conversation, "sim {$pending->code}"); expect($pending->fresh()->status)->toBe(PendingActionStatus::Confirmed); Queue::assertPushed(ExecutePendingAction::class); });
Vale ter também os casos de código errado três vezes, pendência expirada e duas confirmações seguidas (a segunda não pode despachar outro job).
Erros comuns
- O agente responde "pronto, estornei" antes da confirmação. Acontece quando o
tool_resulté vago, tipo{"ok": true}. Devolva um status explícito (aguardando_confirmacao) com instrução, e reforce no system prompt. Mesmo assim, a mensagem que vale é a do sistema, com o código. - Erro 400 da API ao reenviar o histórico com tool sem parâmetros. O
->json()do Laravel decodifica"input": {}como array vazio, e ao reenviar ele vira[], que a API rejeita porque espera um objeto. Por isso o cast(object) $block['input']no passo 5. - Dois estornos pedidos na mesma resposta. Sem
disable_parallel_tool_use, o modelo pode emitir doistool_usede risco e o segundo cancela o primeiro silenciosamente. Desligue o paralelismo ou recuse a segunda pendência com erro notool_result. - Confirmação atrasada pelo agrupamento de mensagens. Se você usa debounce para juntar mensagens picadas antes do agente, intercepte a confirmação antes do debounce. Senão o "SIM 1234" espera segundos à toa ou vai parar no LLM junto com outra frase.
- Dado mudou entre o pedido e a confirmação. A venda foi estornada pelo painel nesse meio tempo, ou o valor foi ajustado. O
executeprecisa revalidar tudo e falhar se o estado divergir do que foi mostrado ao cliente. - Pendência presa em
executing. O worker morreu durante a chamada ao gateway. Não reprocesse automaticamente sem a mesma chave de idempotência. Consulte o provedor e reconcilie.
Perguntas frequentes
Posso usar botões interativos do WhatsApp em vez do código digitado?
Pode, e a experiência fica melhor. Coloque o ID da pendência e o código no payload do botão e valide tudo no servidor, exatamente como no ConfirmationHandler. O botão substitui a digitação, não a validação.
Toda tool de escrita precisa de confirmação?
Não. Ações baratas e reversíveis, como remarcar um lembrete, podem executar direto. Peça confirmação quando a ação mexe em dinheiro, afeta terceiros ou não tem desfazer simples.
O modelo pode cancelar uma pendência?
Eu não deixo. Se o cliente mudar de ideia no meio da conversa, o agente cria uma nova pendência, que cancela a anterior no próprio dispatcher. Cancelamento explícito fica com o "NÃO" do usuário ou com a expiração.
Isso funciona com OpenAI ou Gemini?
Funciona. O padrão vive no dispatcher, na tabela e no handler, que não dependem do provedor. Só muda o formato dos blocos de tool call e de resultado no loop do passo 5.
Quanto tempo deixar a pendência aberta?
Entre 5 e 15 minutos cobre o cliente que foi conferir o extrato antes de confirmar. Mais que isso aumenta a chance de o estado mudar e de alguém confirmar algo de que já não lembra.
Conclusão
O ponto central é tirar a decisão do modelo e colocar no código. O LLM continua útil para entender o pedido e escolher a ação. Quem monta o resumo, valida o código, trava a execução e fala com o gateway é o Laravel, com regras determinísticas e testáveis. Se for implementar só uma parte hoje, comece pela tabela com índice único parcial e pela interceptação da confirmação antes do agente. Essas duas peças já impedem o pior cenário, que é o modelo estornar dinheiro porque leu algo que não devia.