Confirmação humana em agentes de IA: como evitar besteira
Como faço confirmação humana em agentes de IA no LedgerJob: ação irreversível vira card, o usuário clica e o resultado volta ao histórico do agente.
Um agente de IA que só lê dados erra e você corrige na próxima pergunta. Um agente de IA que manda e-mail para o cliente, cobra um cartão ou apaga um registro erra uma vez só. Mostro aqui como implementei confirmação humana (o padrão human in the loop) no agente do LedgerJob, com trechos do código real.
O LedgerJob é um SaaS para empresas de construção nos EUA. O dono da empresa opera leads, orçamentos, projetos e faturas conversando com um agente. São 66 ferramentas, e 16 delas exigem confirmação humana antes de rodar.
O que dá errado sem confirmação humana
O modelo chama ferramentas com base no que entendeu da conversa. Os problemas aparecem nas bordas:
- O usuário pede "manda o orçamento do Smith" e existem dois clientes Smith. O modelo escolhe um.
- O usuário diz "pode apagar aquele lead" três mensagens depois de falar de outro lead.
- O modelo afirma "e-mail enviado" quando a ferramenta falhou ou nem foi chamada.
Numa busca, custa uma pergunta a mais. Num e-mail com PDF anexo indo para o cliente errado, não tem desfazer. A ideia do padrão é simples: o agente pode propor qualquer coisa, mas o que é irreversível só executa depois do clique de uma pessoa.
Passo 1: classificar as ferramentas
Cada ferramenta precisa dizer de que tipo ela é. No LedgerJob isso fica na própria definição dela:
export interface AssistantTool {
name: string;
// ...description, parameters, label
/** Só lê dados. Vira `readOnlyHint` no MCP. */
readOnly?: boolean;
/** Ação irreversível/externa: NÃO executa no loop, vira card de confirmação. */
requiresConfirmation?: boolean;
/** Texto humano do card de confirmação (pode consultar o banco). */
confirmSummary?: (ctx: AssistantToolContext, args: any) => Promise<string>;
execute: (ctx: AssistantToolContext, args: any) => Promise<unknown>;
}
Ficam em três grupos. Leitura (buscar cliente, listar faturas vencidas) roda direto. Escrita reversível (criar orçamento em rascunho, mover um lead no kanban) também roda direto, porque dá para corrigir depois. E as irreversíveis: enviar orçamento ou fatura por e-mail, mandar SMS, apagar lead, cliente, orçamento ou fatura, converter lead em cliente, pedir uma medição aérea paga à EagleView, cancelar um contrato de serviço. Essas 16 levam requiresConfirmation: true.
O critério que uso: sai da empresa (e-mail, SMS), gasta dinheiro com terceiro, ou muda dado de forma permanente. Se a resposta for sim para qualquer um, pede confirmação.
Passo 2: a ação pendente
No loop do agente, quando o modelo chama uma ferramenta marcada, ela não executa. O backend grava uma ação pendente no banco, manda um evento para a interface e devolve ao modelo um resultado que diz claramente que nada aconteceu:
} else if (tool.requiresConfirmation) {
// Gate: não executa. Cria pendência + card na UI; o modelo é avisado.
const args = call.arguments ? JSON.parse(call.arguments) : {};
const summary = tool.confirmSummary
? await tool.confirmSummary(ctx, args)
: tool.label;
const action = await assistantService.createPendingAction(
opts.conversationId, opts.tenantId, opts.userId, call.name, args, summary
);
opts.send('confirm', { actionId: action.id, name: call.name, label: tool.label, summary });
output = JSON.stringify({
status: 'pending_user_confirmation',
note: 'The action was proposed to the user as a confirmation card. It has NOT been executed. Tell the user to confirm it. Do not claim it was done.',
});
}
Dois detalhes importam aqui. O confirmSummary consulta o banco para montar um texto que a pessoa entende, com número do orçamento, título, valor, nome e e-mail do destinatário, em vez de mostrar um uuid. Se o e-mail estiver errado, a pessoa vê antes de clicar. E o resultado devolvido ao modelo é explícito: "NOT been executed". As instruções do agente reforçam a mesma regra: quando a ferramenta devolve pending_user_confirmation, peça para o usuário confirmar o card e nunca diga que a ação foi feita.
Passo 3: a confirmação na interface
A interface recebe o evento confirm pelo mesmo stream (SSE) da resposta e mostra um card com o resumo e dois botões. O clique chama uma rota separada, que aceita só confirm ou reject.
A busca da ação pendente filtra por tenant, usuário, conversa e status: 'pending'. Ninguém confirma a ação de outro usuário. Um clique duplo não executa duas vezes, porque depois do primeiro o status deixa de ser pending. E existe uma regra de expiração: quando o usuário manda uma mensagem nova, todas as propostas pendentes daquela conversa viram expired. Uma proposta antiga não pode ser confirmada fora do contexto em que foi feita.
Passo 4: devolver o resultado ao histórico
O clique acontece fora do loop do modelo. Se o resultado não voltar para o histórico, na mensagem seguinte o agente não sabe se o e-mail saiu, falhou ou foi recusado. Aí ele chuta.
No LedgerJob, os três desfechos viram uma mensagem no histórico da conversa:
if (decision === 'reject') {
await assistantService.markPendingAction(actionId, 'rejected');
await assistantService.appendMessage(conversationId, ctx.tenantId, 'system', [
{ role: 'developer', content: `The user REJECTED the proposed action "${action.toolName}" (${action.summary}). It was not executed.` },
]);
return { status: 'rejected' };
}
Se confirmou, a ferramenta executa e o resultado entra no histórico (cortado em 2.000 caracteres). Se falhou depois do clique, a mensagem diz por quê.
Passo 5: proteger a regra de negócio no service
Confirmação humana protege contra o agente fazer a coisa errada. Não protege contra o agente fazer a coisa certa com dados inválidos. Para isso, a validação precisa estar onde todo caminho passa.
Um bug real mostrou isso. O formulário web validava que o fim do agendamento vem depois do início, mas essa validação estava no DTO da rota HTTP. O agente e o servidor MCP chamam o service direto, então o assistente conseguia agendar "das 18h às 17h". O Google Calendar recusa esse evento, então o agendamento ficava salvo no LedgerJob e nunca chegava à agenda da equipe. A correção foi levar a regra para o service, com teste:
/**
* Mora no service porque os schemas do DTO só cobrem a rota HTTP:
* o servidor MCP (assistente) chama este service direto e passava por fora.
*/
function assertEndAfterStart(start: Date | string, end: Date | string) {
if (new Date(end) <= new Date(start)) {
throw new Error('endDatetime must be after startDatetime');
}
}
A regra que tirei disso: se um agente de IA tem acesso a uma função, a validação mora dentro da função, não na porta HTTP.
Passo 6: limites no loop
Mais três limites completam o desenho:
- Teto de 8 iterações por turno. Se o modelo entrar num ciclo de chamar ferramentas sem parar, o turno termina.
- Resultado de ferramenta cortado em 6.000 caracteres, com um aviso pedindo um filtro mais específico. Uma listagem grande não estoura o contexto nem a linha do banco onde o histórico é salvo.
- Identidade vem da API key ou da sessão, nunca do modelo. Tenant, usuário e papel entram no contexto pelo backend. O modelo não tem como passar um
tenantIddiferente como argumento.
E quando o agente é externo, via MCP?
As mesmas 66 ferramentas ficam disponíveis por um servidor MCP, para Claude Code, Codex ou outro cliente. Lá não existe o card do LedgerJob. A aprovação fica com o cliente MCP, que tem o próprio pedido de permissão. Meu trabalho é dar a informação certa para ele decidir:
description: t.requiresConfirmation
? `IRREVERSIBLE: sends a message, spends money or permanently changes data. Ask the user before calling. ${t.description}`
: t.description,
annotations: {
readOnlyHint: Boolean(t.readOnly),
destructiveHint: Boolean(t.requiresConfirmation),
},
O prefixo IRREVERSIBLE é lido pelo modelo. As anotações readOnlyHint e destructiveHint são lidas pelo cliente, que usa isso para decidir o que roda sem perguntar. A mesma flag requiresConfirmation alimenta os dois lados, então uma ferramenta nova já nasce classificada no chat e no MCP.
Resumo do padrão
- Classifique cada ferramenta: leitura, escrita reversível, irreversível.
- Irreversível vira ação pendente; o modelo recebe "não executado".
- Card com texto humano; a rota valida dono, conversa e status.
- O desfecho volta ao histórico.
- Regra de negócio no service.
- Limite iterações e tamanho de resultado; identidade nunca vem do modelo.
Na home deste site, o card do LedgerJob tem uma demonstração em loop desse fluxo: o pedido, o card aguardando, o clique e a ação executada. Os detalhes do projeto estão em LedgerJob.
Se você está colocando agentes de IA para agir sobre dados reais e quer conversar sobre isso, fale comigo.
Todos os artigos