Skip to content

023 — Edge cases conversacionais (frustração, loop, pedido de humano)

Pesquisa NotebookLM (mel_finance, 2026-06-03) bateu na régua: depois de alucinação (cenário 021), os três failure modes mais caros em agente conversacional financeiro são frustração explícita (“esse bot é uma porra”), loop sem saída (casal repete a mesma pergunta 3x sem o bot resolver), e pedido explícito de humano (“quero falar com uma pessoa”). Em todos os três, resposta genérica do LLM piora — frustrado vira mais frustrado, em loop abandona, e quem pede humano e é ignorado nunca mais volta.

Régua oposta: detectar early + preemptar o LLM com resposta canônica empática (alinhada com ADR 008 — tom “mel”). Custo zero token, determinístico, e tom controlado por code review em vez de prompt-engineering. Esse cenário introduz ConversationGuard como Domain Service puro em agent/domain/services/ (mesma régua de FeasibilityCheck e ExpenseReconciler — sem I/O, só inspeciona estado da conversa). AgentChat.ask consulta o guard antes de chamar o LLM; se preempt, retorna a string canônica e zero tool-calls.

ConversationGuard.evaluate recebe {messages: ChatMessage[], pastTurnsWithoutToolCall?: number} e devolve GuardVerdict. Decisão central: pastTurnsWithoutToolCall é input opcional do caller, não computado pelo guard. Razão: histórico de tool-calls vive fora do messages (no log do AgentChat/router) e calcular Jaccard sobre o array completo de messages adiciona complexidade que não paga — caller (router whats / UI web) já sabe quantos turns passaram sem resolver e passa o número. Mantém o guard simples e testável sem fixture grande.

Resposta canônica preempt não passa pelo LLM — gateway/router idealmente loga ocorrência (auditoria + curadoria semanal), mas isso é instrumentation fora do escopo do guard. Spec valida só comportamento observável (resposta + ausência de LLM call).

Prioridade quando múltiplos triggers matcham

Section titled “Prioridade quando múltiplos triggers matcham”

Quando uma mensagem dispara mais de um detector (ex: caps + xingamento + “quero humano”), o guard segue ordem fixa:

PrioridadeKindCritério
1frustratedRegex de xingamento OU caps abuse no último user message
2human-requestRegex /falar com (humano|pessoa|atendente|gente|alguém)/ etc.
3looppastTurnsWithoutToolCall >= 2 + Jaccard > 0.7 entre últimas 3 user msgs

Razão: emoção crua (frustrated) é o sinal mais urgente — acalmar primeiro, ofertar humano depois. Loop é o último porque só dispara via input opcional do caller.

Scenario: Cliente frustrado é detectado e recebe resposta empática

Section titled “Scenario: Cliente frustrado é detectado e recebe resposta empática”
  • Given um AgentChat configurado com ConversationGuard ativo e LLM mockado capturando call count
  • When o casal envia "esse bot é uma porra, não funciona NADA"
  • And agentChat.ask({messages: [<msg>]}) é invocado
  • Then ConversationGuard.evaluate({messages}) retorna {preempt: true, kind: "frustrated", response: GUARD_RESPONSE_FRUSTRATED}
  • And agentChat.ask resolve com reply.role === "assistant" e reply.content bate em /entendo|frustrad|bora tentar/i
  • And o LLM NÃO foi chamado (mockLLM.callCount === 0) — preempt economiza token + latência
  • And toolCalls.length === 0 (nenhuma tool invocada)

Scenario: Loop detectado (3 msgs similares sem tool útil)

Section titled “Scenario: Loop detectado (3 msgs similares sem tool útil)”
  • Given o casal já enviou ["qual o saldo?", "saldo?"] em turns anteriores sem o agente resolver (nenhuma tool útil disparada)
  • And caller passa pastTurnsWithoutToolCall: 2 indicando os dois turns sem resolução
  • When o casal envia "saldo mesmo?" (terceira tentativa similar)
  • And agentChat.ask({messages: [...história, <msg>], pastTurnsWithoutToolCall: 2}) é invocado
  • Then ConversationGuard.evaluate detecta Jaccard > 0.7 entre as 3 últimas user msgs E pastTurnsWithoutToolCall >= 2 → retorna {preempt: true, kind: "loop", response: GUARD_RESPONSE_LOOP}
  • And reply.content bate em /travei|reformul|humano/i
  • And o LLM NÃO foi chamado nesse turn (mockLLM.callCount === 0)
  • And toolCalls.length === 0

Scenario: Pedido explícito de humano é reconhecido transparentemente

Section titled “Scenario: Pedido explícito de humano é reconhecido transparentemente”
  • Given um AgentChat configurado com ConversationGuard ativo e LLM mockado capturando call count
  • When o casal envia "quero falar com uma pessoa, não com bot"
  • And agentChat.ask({messages: [<msg>]}) é invocado
  • Then ConversationGuard.evaluate retorna {preempt: true, kind: "human-request", response: GUARD_RESPONSE_HUMAN_REQUEST}
  • And reply.content bate em /só eu|por enquanto|sem humano|anoto.*pedido/i (transparência sobre ser robô — alinha com ADR 008)
  • And reply.content bate em /orçamento|meta|fatura/i (oferece pelo menos uma capability como alternativa concreta)
  • And o LLM NÃO foi chamado (mockLLM.callCount === 0)
  • And toolCalls.length === 0

Scenario: Mensagem normal NÃO preempta (passa pro LLM)

Section titled “Scenario: Mensagem normal NÃO preempta (passa pro LLM)”
  • Given um AgentChat configurado com BudgetTool + ConversationGuard ativo e LLM mockado decidindo chamar budgetTotal({month: "2026-06"}) + responder em PT-BR
  • When o casal envia "qual o total do mês?" (sem frustração, sem human-request, sem repetição)
  • And agentChat.ask({messages: [<msg>]}) é invocado
  • Then ConversationGuard.evaluate retorna {preempt: false} (nenhum trigger bate)
  • And o LLM FOI chamado normalmente (mockLLM.callCount > 0)
  • And toolCalls contém uma entrada com name === "budgetTotal"
  • And reply.content é o texto que o mock devolveu (não a canonical response)

Scenario: Frustração + pedido humano combinados → kind = “frustrated” (prioridade)

Section titled “Scenario: Frustração + pedido humano combinados → kind = “frustrated” (prioridade)”
  • Given um AgentChat configurado com ConversationGuard ativo
  • When o casal envia "VAI A MERDA, QUERO FALAR COM HUMANO" (caps abuse + xingamento + pedido de humano simultâneos)
  • And agentChat.ask({messages: [<msg>]}) é invocado
  • Then ConversationGuard.evaluate retorna {preempt: true, kind: "frustrated"} — frustrated tem prioridade sobre human-request quando ambos matcham (regra documentada acima)
  • And reply.content === GUARD_RESPONSE_FRUSTRATED (resposta canônica de frustrated, não a de human-request)
  • And o LLM NÃO foi chamado
  • ConversationGuard — Domain Service puro em src/contexts/agent/domain/services/. Sem I/O, sem mutação, sem dependência externa. Recebe POJO de input (messages, pastTurnsWithoutToolCall?), devolve POJO de saída (GuardVerdict). Mesma régua de FeasibilityCheck (006) e ExpenseReconciler (004).
  • GuardVerdict — POJO discriminado pelo campo preempt:
    type GuardVerdict =
    | { preempt: false }
    | { preempt: true; kind: "frustrated" | "loop" | "human-request"; response: string };
    Quando preempt:false, sem outros campos (consumer cai no fluxo normal). Quando preempt:true, kind revela o detector que disparou e response carrega a string canônica pronta pra emitir.
  • Canonical responses — constantes PT-BR exportadas:
    • GUARD_RESPONSE_FRUSTRATED: “Vi que tu tá frustrado e eu entendo. Bora tentar de outro jeito — me conta o que tu precisa em 1 frase curta?”
    • GUARD_RESPONSE_LOOP: “Acho que travei. Não consegui te ajudar com isso. Quer reformular ou esperar um humano entrar pra te ajudar mais tarde?”
    • GUARD_RESPONSE_HUMAN_REQUEST: “Por enquanto só eu (mel) por aqui — sem humano. Anoto o pedido pra avisar quando suporte entrar. Enquanto isso, posso te ajudar com orçamento, metas ou fatura?” Strings hard-coded PT-BR no domínio (gotcha “message humano em PT no domínio enquanto monolíngue”). Alinhadas com ADR 008 — empático, sem juridiquês, oferece path. Exportadas pra eval harness (022 planejado) checar via substring + pra spec assertar identidade exata.
  • AgentChat.ask extendido — antes de invocar LLM:
    1. Chama ConversationGuard.evaluate({messages, pastTurnsWithoutToolCall}).
    2. Se verdict.preempt === true, monta {reply: {role:"assistant", content: verdict.response}, toolCalls: []} e retorna direto (sem LLM, sem tools).
    3. Senão, segue fluxo 008 normal (LLM + tools + multi-round). Back-compat: caller que não passa pastTurnsWithoutToolCall continua funcionando (loop nunca dispara, frustrated/human-request seguem funcionando via regex).
src/contexts/agent/domain/services/ConversationGuard.ts
export type GuardVerdict =
| { preempt: false }
| { preempt: true; kind: "frustrated" | "loop" | "human-request"; response: string };
export interface GuardInput {
messages: ChatMessage[]; // mesmo VO do 008
pastTurnsWithoutToolCall?: number; // sinal opcional pro detector de loop
}
export const ConversationGuard: {
evaluate(input: GuardInput): GuardVerdict;
};
export const GUARD_RESPONSE_FRUSTRATED: string;
export const GUARD_RESPONSE_LOOP: string;
export const GUARD_RESPONSE_HUMAN_REQUEST: string;
// src/contexts/agent/domain/AgentChat.ts (extensão, sem breaking)
agentChat.ask(input: {
messages: ChatMessage[];
pastTurnsWithoutToolCall?: number; // novo opcional, default undefined → loop não dispara
resourceId?: string;
threadId?: string;
}): Promise<{ reply: ChatMessage; toolCalls: Array<{...}> }>
  • Preempt antes do LLM, não filtragem da resposta — gargalo é detectar antes de pagar token. Filtrar resposta do LLM ex-post (ex: “se LLM disse X, substitui”) gasta o turn e ainda corre risco de wording ruim escapar. Preempt corta a chamada na fonte: zero token, determinístico, tom blindado por code review.
  • Domain Service puro, sem I/OConversationGuard vive em agent/domain/services/. Não toca repo, gateway, LLM. Input é POJO completo; output é POJO. Mesma régua de FeasibilityCheck/ExpenseReconciler/BudgetAlerts. Testável em ms, reuso trivial entre canais (whats, UI web, futuros).
  • pastTurnsWithoutToolCall é input opcional, não computado — guard NÃO percorre messages pra contar turns sem tool. Razão: (a) ChatMessage não carrega “tool-call resolveu o turn?” — caller (AgentChat / router) tem essa info no log de toolCalls; (b) calcular Jaccard sobre o histórico inteiro adiciona complexidade que não paga MVP; (c) input explícito é honesto — caller sinaliza “olha, casal tá em loop”, guard age. Quando aparecer caso “guard precisa decidir sozinho”, promove a heurística interna ou tira do escopo do guard.
  • Loop não dispara sem o sinal do callerpastTurnsWithoutToolCall: undefined | 0 | 1 → loop fica off. Default conservador: assume “tudo bem” até caller dizer o contrário. Evita false positive em conversas onde casal faz follow-up legítimo (“e o mercado?” depois de “e a luz?”).
  • Jaccard token similarity > 0.7 — aproximação MVP — tokenização naive (split por whitespace + lowercase + remove punctuation), Jaccard |A∩B| / |A∪B|. Sem stemming, sem embedding. Funciona pra “qual o saldo?” / “saldo?” / “saldo mesmo?” (overlap alto). False positive esperado em conversas variadas (Jaccard alto sem ser loop real); contramedida primária é o sinal pastTurnsWithoutToolCall do caller — sem ele, Jaccard isolado não dispara loop. Quando aparecer false positive real, refina (threshold, n-gram, ou embedding).
  • Ordem de detectores: frustrated > human-request > loop — emoção crua urgente acalmar; pedido de humano é deliberado (resposta canônica oferece transparência); loop é estado, não urgência. Documentado na tabela acima. Spec valida a ordem (cenário “frustração + pedido humano combinados”).
  • Regex hard-coded no módulo, não regex configurável/lixo|burro|inútil|merda|porra|não funciona|horrível|odeio/i mora dentro do ConversationGuard. Vocabulário curto, PT-BR, atualizável via code review. Promover a config (lista por casal, override por canal) entra quando aparecer caso real (“casal usa ‘porra’ como expressão neutra”). Custo de ter como config hoje > custo de mudar depois.
  • Caps abuse heurística simplestext.length > 5 && [...text].filter(c => c === c.toUpperCase() && c !== c.toLowerCase()).length / [...text].filter(c => c !== ' ').length > 0.5. Detecta “VAI A MERDA, QUERO FALAR COM HUMANO” mesmo sem palavrão claro. False positive em siglas longas (“ABC DEF GHI”); contramedida: pelo menos 5 chars já filtra “OK” / “VC”.
  • response carregada no GuardVerdict — caller não precisa importar as constantes pra responder, é só repassar verdict.response. As constantes ficam exportadas pra (a) spec assertar identidade exata (não regex), (b) eval harness (022) checar versão atual da string. Caller que quer wrappar/decorar (ex: prefixar nome do member no whats) pode — response é só o conteúdo bruto.
  • Preempt ainda atualiza history + working memory — decisão diferida pra impl (spec não asserta). Razão de design: continuidade pra audit/curadoria semanal de failure modes. Working memory provavelmente NÃO muda (LLM não rodou; não há fact pra extrair); history sim (user msg + assistant response gravados como qualquer turn). Quando aparecer cenário de eval/audit, formaliza via cenário próprio.
  • Não loga preempt no domínio — instrumentation (quantas vezes frustrated disparou esse mês? loop em qual canal?) fica em camada de aplicação, não no Domain Service. Guard é puro: input → output. Logger fica no router/adapter que chama o guard.
  • Texto da response é asserted via regex tolerante OU substring exato com a constante — spec usa regex pra wording flexível (/entendo|frustrad/i) E também expect(reply.content).toBe(GUARD_RESPONSE_FRUSTRATED) em pelo menos um cenário pra fechar contract. Mistura aproveita o melhor dos dois: regex resiste a tweak fino, substring fecha que a string canônica é a fonte da verdade.
  • mockLLM.callCount é parte do contract observável — helper local mockLLMSpy() retorna {model, callCount, lastPrompt}. Spec asserta callCount === 0 no preempt e > 0 no caminho normal. Garante que preempt realmente evita o LLM (não só “produz resposta canônica mas ainda chama o LLM por dentro”).
  • Cenário 008 — sem touchup nos specs existentes. AgentChat.ask ganha branch preempt antes do LLM call; mensagens normais continuam caindo em {preempt:false} e fluxo 008 segue idêntico.
  • Cenário 012 — sem touchup. AgentChat recebe resourceId/threadId por extensão de contract (já existente). Guard roda antes da persistência LLM-driven; impl decide se persiste preempt (provavelmente sim, pra audit).
  • Cenário 013 — sem touchup. Write tools rodam pelo LLM (propose→confirm); preempt corta antes do LLM, então write tools nem chegam a ser invocadas. Edge case “casal frustrado durante write flow” tratado por preempt.
  • Cenário 021 — paralelo. 021 trata “agente não sabe” via system prompt + tool absence; 023 trata “casal não tá bem” via preempt determinístico. Os dois mecanismos coexistem em AgentChat.ask:
    1. Preempt guard (023) — preempt LLM, retorna canonical.
    2. Se não preempt, system prompt inclui anti-hallucination (021) + working memory (012) + voice guide (ADR 008).
  • wa-001/002/003 — canal whats herda o guard automaticamente (router chama agentChat.ask). Failure modes do whats (frustração em DM, loop no upload de fatura, terceiro pedindo humano no group) ganham tratamento centralizado.
  • Logging/telemetria de preempt — quantas vezes frustrated/loop/human-request disparou. Camada de aplicação, fora do Domain Service. Cenário futuro.
  • Configuração de threshold por casalpastTurnsWithoutToolCall >= 2 é o gate de loop; Jaccard > 0.7 é o threshold de similaridade. Hard-coded. Quando aparecer “casal X tolera repetição maior”, promove a parâmetro.
  • Vocabulário de frustração customizável — regex fixa hoje. Internacionalização (i18n) implica regex por locale, junto com response por locale.
  • Detecção via LLM (semantic intent classifier) — guard hoje é regex + Jaccard. Substituir por LLM intent classifier perde determinismo + custa token + lentidão. Quando aparecer false positive massivo, considera embedding (não LLM-call) como middle ground.
  • Resposta canônica multi-turn — preempt resolve em 1 turn. Se casal continua frustrado depois do preempt, próximo turn dispara guard de novo (estado é stateless — cada ask reavalia). Multi-turn coordenação (ex: “depois do segundo preempt frustrated, escalata pra humano de verdade”) é fora.
  • Handoff real pra humanohuman-request resposta canônica é transparência (“não tem humano hoje”). Sistema real de handoff (push pra Slack do suporte, registro em fila) é cenário futuro quando suporte humano existir.
  • Preempt em assistant/tool messages — guard inspeciona só último user message (índice mais simples). Frustração emergindo no meio de uma sequência tool-call não é detectada hoje; primeiro user message depois disso pega.
  • i18n das constantes — PT-BR direto, gotcha “message humano em PT no domínio”. Quando i18n entrar, vira {key, params} ou getGuardResponse(locale, kind).
  1. Criar ConversationGuard em src/contexts/agent/domain/services/ConversationGuard.ts com:
    • constantes GUARD_RESPONSE_FRUSTRATED, GUARD_RESPONSE_LOOP, GUARD_RESPONSE_HUMAN_REQUEST (PT-BR);
    • regex FRUSTRATION_REGEX + helper capsAbuse(text): boolean;
    • regex HUMAN_REQUEST_REGEX;
    • helper jaccard(a: string, b: string): number (tokeniza + similaridade);
    • função evaluate({messages, pastTurnsWithoutToolCall}) aplicando os 3 detectores em ordem.
  2. Estender AgentChat.ask em src/contexts/agent/domain/AgentChat.ts:
    • aceita pastTurnsWithoutToolCall?: number (back-compat: opcional);
    • chama ConversationGuard.evaluate antes do LLM;
    • se preempt, retorna {reply: {role:"assistant", content: verdict.response}, toolCalls: []} direto.
  3. Atualizar barrel src/contexts/agent/domain/index.ts exportando ConversationGuard, GuardVerdict, e as três constantes.
  4. Passar os 5 scenarios. Specs 008/012/013/021 continuam verdes sem touchup.