Skip to content

021 — Agente admite limite (anti-hallucination)

Pesquisa NotebookLM (mel_finance, 2026-06-03) bateu na régua: alucinar é o failure mode mais caro em agentes financeiros. Inventar saldo do mês, prazo da fatura ou regra de cartão destrói confiança imediatamente — e custa muito mais que admitir “não sei”. Régua oposta: agente DEVE admitir limite explicitamente + oferecer alternativa concreta (“não tenho isso, mas posso te ajudar com X”). Transparência sobre ser robô aumenta tolerância a erros.

Esse cenário codifica o anti-hallucination contract em três camadas, sem inventar conceito novo nem mexer no domínio financeiro — só refina como o AgentChat (008) e as tools de leitura conversam:

  1. Read tools devolvem absence explícita — shape { found: false, message?: string } quando o dado não existe (vs throw, vs retornar Money.zero ambíguo). Caller (LLM) distingue “não existe” de “erro técnico” via o discriminador found. Convenção uniforme entre BudgetTool, GoalTool, FeasibilityTool (e qualquer read tool futura).
  2. System prompt ruleAgentChat injeta o fragmento constante ANTI_HALLUCINATION_RULE (em PT-BR, hard-coded em agent/domain/) no system prompt do LLM. Instrui: “se tool retornar found:false ou tu não tem dado, admita + ofereça path alternativo. Nunca invente saldo/prazo/regra”.
  3. Error wrapping — tool throw é capturada pelo AgentChat antes de mandar pro LLM. Devolve { error: true, friendly: "houve um erro técnico, tenta de novo em uns minutos" } como tool result. Stack trace, mensagens internas (“DB connection lost”), nada disso vaza pro contexto do LLM (e portanto pro casal). LLM lê só o friendly e responde no tom apropriado.

Spec roda contra MockLanguageModelV1 (ADR 002). O mock controla 100% da wording da resposta — spec valida só (a) padrões mínimos via regex tolerante (admissão + redirecionamento), (b) shape dos resultados das tools, (c) presença do fragmento anti-hallucination no system prompt enviado ao LLM (capturado pelo prompt que o mock recebe).

Backward compat preservada: tools existentes (008) continuam funcionando idênticas quando dado existe. Mudança de shape entra só no caso vazio.

Scenario: Tool retorna found:false → agente admite explicitamente

Section titled “Scenario: Tool retorna found:false → agente admite explicitamente”
  • Given um Budget da casa em BRL vazio (sem nenhum RecurringExpense registrado)
  • And um AgentChat configurado com BudgetTool apontando pra esse Budget
  • And o LLM mockado, no primeiro turno, decide chamar budgetTotal({ month: "2026-06" }) e depois responder em PT-BR
  • And o LLM mockado, após receber o tool result { found: false, message: "sem dados pra esse período ainda" }, responde no segundo turno com texto "Ainda não tenho registros pra junho/2026. Quer começar registrando seus custos?"
  • When o casal envia "qual o total do mês?"
  • Then o agente chama BudgetTool.budgetTotal com { month: "2026-06" }
  • And o resultado da tool é { found: false, message: "sem dados pra esse período ainda" } (não Money.zero, não throw)
  • And a resposta final do agente bate em /ainda não|sem dado|sem registro/i (admissão explícita)
  • And a resposta final do agente bate em /quer começar|posso te ajudar|registr/i (oferece alternativa concreta)
  • And BudgetTool.budgetTotal foi chamada exatamente uma vez

Scenario: Goal inexistente → admite + lista metas existentes

Section titled “Scenario: Goal inexistente → admite + lista metas existentes”
  • Given um Household “Casa” em BRL com Gabriel e esposa como members
  • And a meta “Lua de mel” target €5.000 deadline 01/06/2027 persistida e referenciada pela GoalTool
  • And o LLM mockado, no primeiro turno, decide chamar goalStatus({ name: "Casa nova", today: "2026-06-01" }) (meta que NÃO existe)
  • And o LLM mockado, após receber o tool result { found: false, message: "meta 'Casa nova' não existe ainda" }, responde no segundo turno com texto "Vocês não têm uma meta 'Casa nova'. Hoje vocês têm 'Lua de mel'. Quer criar uma nova?"
  • When o casal envia "como tá a meta da casa?"
  • Then o agente chama GoalTool.goalStatus com { name: "Casa nova", today: "2026-06-01" }
  • And o resultado da tool é { found: false, message: "meta 'Casa nova' não existe ainda" }
  • And a resposta final do agente bate em /não tem|não existe|ainda não/i (admissão)
  • And a resposta final do agente contém a string "Lua de mel" (sugere alternativa concreta com nome da meta que existe)

Scenario: Pergunta fora-de-escopo (sem tool aplicável) → admite + redireciona

Section titled “Scenario: Pergunta fora-de-escopo (sem tool aplicável) → admite + redireciona”
  • Given um AgentChat configurado com todas as read tools padrão (BudgetTool, GoalTool, FeasibilityTool)
  • And o LLM mockado decide responder direto em texto (sem chamar nenhuma tool) com "Isso eu não sei te dizer. Posso te ajudar com orçamento do casal, metas ou faturas. Quer ver algum desses?"
  • When o casal envia "qual a previsão do bitcoin amanhã?" (pergunta fora do escopo financeiro do casal)
  • Then a resposta final do agente bate em /não sei|não tenho|isso eu/i (admissão de limite)
  • And a resposta final do agente menciona pelo menos uma das capabilities do agente — bate em /orçamento|meta|fatura/i
  • And nenhuma tool foi chamada (toolCalls.length === 0) — agente reconheceu fora-de-escopo sem brute-force de tools

Scenario: Tool throw → agente reporta erro friendly (não vaza stack)

Section titled “Scenario: Tool throw → agente reporta erro friendly (não vaza stack)”
  • Given um AgentChat configurado com uma BudgetTool cujo método budgetTotal dá throw com Error("DB connection lost") (simula bug de infra)
  • And o LLM mockado, no primeiro turno, decide chamar budgetTotal({ month: "2026-06" })
  • And o LLM mockado, após receber o tool result { error: true, friendly: "houve um erro técnico, tenta de novo em uns minutos" }, responde no segundo turno com texto "Tive um problema técnico. Pode tentar de novo em alguns minutos?"
  • When o casal envia "qual o total do mês?"
  • Then o agente chama BudgetTool.budgetTotal (que dá throw internamente)
  • And o AgentChat captura o throw e o tool result entregue ao LLM é { error: true, friendly: "houve um erro técnico, tenta de novo em uns minutos" } (NÃO vaza stack nem mensagem interna)
  • And o tool result NÃO bate em /DB|connection lost|stack|undefined/i (mensagem interna não vaza)
  • And a resposta final do agente bate em /erro|problema|tentar de novo/i (reporta amigavelmente)
  • And a resposta final do agente NÃO bate em /DB|connection|stack|undefined/i (LLM não viu lixo, logo não pode propagar)

Scenario: System prompt contém regra anti-hallucination

Section titled “Scenario: System prompt contém regra anti-hallucination”
  • Given um AgentChat configurado com tools padrão e o LLM mockado capturando o prompt recebido em cada chamada
  • When o casal envia "oi" (qualquer mensagem que dispare uma chamada ao LLM)
  • Then o prompt recebido pelo MockLanguageModelV1 contém uma mensagem system cujo texto bate em /admit|não invent|alternativa/i (fragmento anti-hallucination presente)
  • And o fragmento é o mesmo conteúdo exportado pela constante ANTI_HALLUCINATION_RULE em src/contexts/agent/domain/ (assert por substring)
  • Convention { found: false, message?: string } — convenção uniforme entre todas as read tools do agente. Não é um VO novo: é um shape POJO documentado. BudgetTool.budgetTotal, GoalTool.goalStatus, FeasibilityTool.feasibility passam a retornar:
    • happy path (dado existe): shape original do cenário 008 ({ expected, period }, { saved, requiredMonthly, ... }, etc).
    • caso vazio (dado não existe): { found: false, message: "..." }. Discriminador é o campo found (presente só no caso vazio). Caller diferencia via if ("found" in result && result.found === false) ou checando shape.
  • Constante ANTI_HALLUCINATION_RULE — string PT-BR hard-coded em src/contexts/agent/domain/AntiHallucinationRule.ts. Exportada pra reuso (test pode importar e assertar substring contained no prompt). Wording sugerido (ajustável):

    “Você é um agente financeiro pro casal. Se uma tool retornar found:false, ou se você não tiver dados pra responder, admita explicitamente (“não tenho isso ainda”, “essa eu não sei”). Sempre ofereça uma alternativa concreta (“posso te ajudar com X”, “quer registrar Y?”). Nunca invente saldo, prazo, ou regra. Transparência > parecer competente.”

  • ToolErrorEnvelope POJO{ error: true, friendly: string }. Não é VO, é shape do tool result quando tool dá throw. Implementação no AgentChat: try/catch ao redor da invocação da tool. Friendly default "houve um erro técnico, tenta de novo em uns minutos" (i18n quando aparecer, mesma régua do gotcha “message humano em PT no domínio enquanto monolíngue”).
  • AgentChat extendidoask({...}) agora:
    1. Injeta ANTI_HALLUCINATION_RULE no system prompt (junto com working memory do 012).
    2. Pra cada tool invocada, envolve em try/catch — em caso de throw, devolve ToolErrorEnvelope como tool result e segue o turno.
    3. Sem mudança no contract público do ask (mesma assinatura). Back-compat com 008/012/013.
src/contexts/agent/domain/AntiHallucinationRule.ts
export const ANTI_HALLUCINATION_RULE: string = `Você é um agente financeiro pro casal. Se uma tool retornar found:false, ou se você não tiver dados pra responder, admita explicitamente. Sempre ofereça uma alternativa concreta. Nunca invente saldo, prazo, ou regra.`;
// (wording exato fica a critério do impl — spec asserta só regex /admit|não invent|alternativa/i + substring)
// Shape do tool result no caso vazio (read tools)
type AbsenceResult = { found: false; message?: string };
// Exemplos de shape por tool (extensão de 008):
// BudgetTool.budgetTotal({month}): { expected: Money_POJO, period: string } | AbsenceResult
// GoalTool.goalStatus({name, today}): { saved, requiredMonthly, pace, onTrack, forecast } | AbsenceResult
// FeasibilityTool.feasibility({...}): FeasibilityResult | (AbsenceResult & { status?: "indeterminate"; reason?: string })
// src/contexts/agent/domain/tools/ToolErrorEnvelope.ts
export interface ToolErrorEnvelope {
error: true;
friendly: string;
}
  • Convenção de shape > novo tipo{ found: false, message?: string } é convention, não classe. Reduz cerimônia e mantém POJO trivialmente serializável pro LLM consumir. found como discriminador permite type narrowing (if (!result.found) { ... }). Mesma régua de “Result flat com optional fields, não union discriminada” (013/014/015).
  • message?: string opcional, não obrigatório — algumas tools vão querer enriquecer (“meta ‘Casa nova’ não existe ainda — vocês têm ‘Lua de mel’ ativa”); outras só sinalizam ausência. Quando ausente, LLM age só pelo found:false. Mensagem é hint pro LLM, não contract obrigatório.
  • System prompt fragment é constante exportada, não literal espalhadoANTI_HALLUCINATION_RULE mora em src/contexts/agent/domain/AntiHallucinationRule.ts pra reuso (spec importa e assertia substring; impl injeta no prompt). Mudança de wording fica num único arquivo. Quando aparecer i18n, vira {key, params} ou função getAntiHallucinationRule(locale). Hoje PT-BR direto (gotcha “message humano em PT no domínio enquanto monolíngue”).
  • Texto da resposta NÃO é assertado wording exato — spec valida só regex (/ainda não|sem dado|sem registro/i, /quer começar|registr/i, etc). LLM real renderiza prosa natural; mock controla 100% do conteúdo via fila de responses. Asserções precisam tolerar wording variável pra não amarrar o impl. Mesma régua de “Asserções no spec validam tool CALLS, não wording” (008) — exceto pelos padrões mínimos que caracterizam o anti-hallucination (admissão + redirect).
  • Spec inspeciona system prompt via MockLanguageModelV1.doGenerate recebendo prompt — o mock recebe a prompt array completa em cada chamada. Spec usa um wrapper do mock que captura a última prompt recebida (lastPrompt). Assertion sobre lastPrompt é como a gente valida que o fragmento entrou. Sem reflection, sem branch interno do AgentChat exposto — só o que cruza a fronteira pro LLM. Nota: a API exata da prompt passa pelo SDK 4 (ai@4.3.x, gotcha “AI SDK 4.x escolhido por compat com LanguageModelV1”); spec assume doGenerate({prompt}) shape.
  • Error envelope sem kind discriminador adicional{ error: true, friendly } é shape único. Caller (LLM) só lê error: true pra saber que algo deu ruim. Tipos de erro (timeout, validation, infra) ficam invisíveis pro LLM por design — exposição granular seria “vaza implementação”. Quando UX quiser categorizar pra logging (sem mostrar pro casal), adapter externo loga antes do envelope.
  • Throw da tool é capturada pelo AgentChat, NÃO por wrapper individual de cada tool — single point de error handling no orquestrador. Tools podem throwar à vontade (lookup falhou, repo indisponível, etc); AgentChat decide o que vira ToolErrorEnvelope. Reduz duplicação cross-tool e centraliza a regra “stack não vaza”.
  • Cenário fora-de-escopo NÃO chama tool — LLM mock decide direto “não tenho como saber, redireciona” sem brute-force. Realista: prompt + tools schema + system fragment devem instruir LLM a perceber “isso não cabe em nenhuma tool minha”. Spec asserta toolCalls.length === 0 — ausência de chamada é o sinal observável de fora-de-escopo bem tratado.
  • Tools são quem decidem found:false, NÃO AgentChatBudgetTool.budgetTotal lê o Budget e decide se há dado (“budget.list().length === 0” → absence). AgentChat só repassa o resultado pro LLM. Mantém regra “tools são wrappers finos sobre aggregates” (008) — a tool já sabia ler o domínio; agora também sabe descrever quando o domínio está vazio.
  • found vs error são ortogonaisfound:false = “consulta foi feita com sucesso, resposta é: não existe”. error:true = “consulta falhou tecnicamente, não sei se existe ou não”. LLM lida diferente: ausência convida a sugerir criar/cadastrar; erro convida a pedir retry. Não colapsar os dois.
  • Spec valida shape do tool result direto via toolCalls[i].resultAgentChat.ask já expõe toolCalls no retorno (008). Spec inspeciona toolCalls[0].result pra confirmar shape {found:false} ou {error:true}, sem precisar de mock side-channel. Mesma régua do 008.
  • Back-compat absoluta com 008 quando dado existeBudgetTool.budgetTotal({month: "2026-06"}) com Budget cheio continua devolvendo {expected, period} igual sempre devolveu. Tools detectam “dado vazio” antes de montar o resultado e devolvem {found:false} só nesse caminho. Specs 008 não precisam touchup.
  • Cenário 008 — sem touchup nos specs existentes (happy path com Budget cheio continua devolvendo {expected, period}). Impl das tools (BudgetTool, GoalTool, FeasibilityTool) ganha branch “dado vazio → {found:false}” sem quebrar o caminho cheio.
  • Cenário 012AgentChat já injeta system prompt pra working memory. 021 adiciona ANTI_HALLUCINATION_RULE no mesmo entry point. Spec 012 não precisa touchup (continua assertando o que asserta hoje); impl injeta os dois fragmentos em sequência.
  • Cenário 013 — write tools usam ToolReceipt (próprio envelope) com preview/confirmed/persisted. Não viram ToolErrorEnvelope por throw — happy path do 013 não passa por erros. Quando aparecer “write tool dá throw” (escopo futuro), provavelmente reusa ToolErrorEnvelope. Hoje: ortogonal.
  • Cenários 014/015/017/018 — read tools desses cenários (incomeStatus, alerts, etc, quando existirem) seguem a mesma convenção {found:false, message?} no caso vazio. Pattern propagado.
  • Tool result mais granular — campos extras no {found:false} (ex: suggestions: [...], relatedNames: [...]). Hoje só message?. Enriquecer quando aparecer caso que precise.
  • Categorias de erro pro callerToolErrorEnvelope.kind = "timeout" | "validation" | "infra". Hoje shape único. Adiciona quando UX/logging quiser distinguir.
  • Error envelope pra write tools — write tools dão throw em mais lugares (Budget.adjustAmount em expense não encontrado, etc). Hoje ToolReceipt cobre só preview/confirm. Quando write tool throw aparecer como gotcha real, provavelmente reusa ToolErrorEnvelope no mesmo orquestrador.
  • Adapter-level retry policyAgentChat não tenta de novo automaticamente. Casal vê friendly + retry manual. Retry automático fica pra adapter de infra (ex: SDK Vercel já tem). Domain não decide.
  • Recall semântico (“você perguntou isso semana passada”) — Mastra semantic recall (OFF default, ADR 004). 021 só foca em “não inventa”, não em “lembra mais coisa”.
  • i18n do ANTI_HALLUCINATION_RULE e do friendly — PT-BR direto. Quando i18n real chegar, vira {key, params}.
  • Telemetria de quantas vezes o agente admitiu vs respondeu — instrumentation. Útil pra calibrar prompt, mas fora desse cenário.
  • Alucinação detectada post-hoc — guard-rail pra “LLM inventou apesar do prompt” (ex: cross-check da resposta vs tool results). Hoje confiamos no prompt fragment + tool shape como prevenção; detection de hallucination passou fica pra cenário próprio quando aparecer dor.
  • Mensagem customizada por casal (“a Ana prefere tom mais seco”) — wording é global por enquanto. Personalização fica pra cenário futuro.
  1. Criar constante ANTI_HALLUCINATION_RULE em src/contexts/agent/domain/AntiHallucinationRule.ts (string PT-BR exportada).
  2. Criar shape ToolErrorEnvelope em src/contexts/agent/domain/tools/ToolErrorEnvelope.ts (POJO).
  3. Refinar BudgetTool/GoalTool/FeasibilityTool em src/contexts/agent/domain/tools/ — branch “dado vazio → {found:false, message?}”.
  4. Estender AgentChat.ask:
    • injeta ANTI_HALLUCINATION_RULE no system prompt (junto com working memory do 012);
    • try/catch ao redor de cada invocação de tool — throw vira ToolErrorEnvelope como tool result.
  5. Atualizar barrel src/contexts/agent/domain/index.ts com os novos exports.
  6. Passar os 5 scenarios. Specs 008/012/013 continuam verdes sem touchup.