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:
| Prioridade | Kind | Critério |
|---|---|---|
| 1 | frustrated | Regex de xingamento OU caps abuse no último user message |
| 2 | human-request | Regex /falar com (humano|pessoa|atendente|gente|alguém)/ etc. |
| 3 | loop | pastTurnsWithoutToolCall >= 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
AgentChatconfigurado comConversationGuardativo 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.askresolve comreply.role === "assistant"ereply.contentbate 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: 2indicando 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.evaluatedetecta Jaccard > 0.7 entre as 3 últimas user msgs EpastTurnsWithoutToolCall >= 2→ retorna{preempt: true, kind: "loop", response: GUARD_RESPONSE_LOOP} - And
reply.contentbate 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
AgentChatconfigurado comConversationGuardativo 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.evaluateretorna{preempt: true, kind: "human-request", response: GUARD_RESPONSE_HUMAN_REQUEST} - And
reply.contentbate em/só eu|por enquanto|sem humano|anoto.*pedido/i(transparência sobre ser robô — alinha com ADR 008) - And
reply.contentbate 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
AgentChatconfigurado comBudgetTool+ConversationGuardativo e LLM mockado decidindo chamarbudgetTotal({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.evaluateretorna{preempt: false}(nenhum trigger bate) - And o LLM FOI chamado normalmente (
mockLLM.callCount > 0) - And
toolCallscontém uma entrada comname === "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
AgentChatconfigurado comConversationGuardativo - 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.evaluateretorna{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
Modelo
Section titled “Modelo”ConversationGuard— Domain Service puro emsrc/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 deFeasibilityCheck(006) eExpenseReconciler(004).GuardVerdict— POJO discriminado pelo campopreempt:Quandotype GuardVerdict =| { preempt: false }| { preempt: true; kind: "frustrated" | "loop" | "human-request"; response: string };preempt:false, sem outros campos (consumer cai no fluxo normal). Quandopreempt:true,kindrevela o detector que disparou eresponsecarrega 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 “messagehumano 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.askextendido — antes de invocar LLM:- Chama
ConversationGuard.evaluate({messages, pastTurnsWithoutToolCall}). - Se
verdict.preempt === true, monta{reply: {role:"assistant", content: verdict.response}, toolCalls: []}e retorna direto (sem LLM, sem tools). - Senão, segue fluxo 008 normal (LLM + tools + multi-round).
Back-compat: caller que não passa
pastTurnsWithoutToolCallcontinua funcionando (loop nunca dispara, frustrated/human-request seguem funcionando via regex).
- Chama
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<{...}> }>Decisões de design
Section titled “Decisões de design”- 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/O —
ConversationGuardvive emagent/domain/services/. Não toca repo, gateway, LLM. Input é POJO completo; output é POJO. Mesma régua deFeasibilityCheck/ExpenseReconciler/BudgetAlerts. Testável em ms, reuso trivial entre canais (whats, UI web, futuros). pastTurnsWithoutToolCallé input opcional, não computado — guard NÃO percorremessagespra contar turns sem tool. Razão: (a)ChatMessagenã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 caller —
pastTurnsWithoutToolCall: 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 sinalpastTurnsWithoutToolCalldo 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/imora dentro doConversationGuard. 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 simples —
text.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”. responsecarregada noGuardVerdict— caller não precisa importar as constantes pra responder, é só repassarverdict.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émexpect(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 localmockLLMSpy()retorna{model, callCount, lastPrompt}. Spec assertacallCount === 0no preempt e> 0no caminho normal. Garante que preempt realmente evita o LLM (não só “produz resposta canônica mas ainda chama o LLM por dentro”).
Impacto em outros cenários
Section titled “Impacto em outros cenários”- Cenário 008 — sem touchup nos specs existentes.
AgentChat.askganha branch preempt antes do LLM call; mensagens normais continuam caindo em{preempt:false}e fluxo 008 segue idêntico. - Cenário 012 — sem touchup.
AgentChatreceberesourceId/threadIdpor 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:- Preempt guard (023) — preempt LLM, retorna canonical.
- 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.
Fora de escopo
Section titled “Fora de escopo”- 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 casal —
pastTurnsWithoutToolCall >= 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
responsepor 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
askreavalia). Multi-turn coordenação (ex: “depois do segundo preempt frustrated, escalata pra humano de verdade”) é fora. - Handoff real pra humano —
human-requestresposta 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 “
messagehumano em PT no domínio”. Quando i18n entrar, vira{key, params}ougetGuardResponse(locale, kind).
Próximo passo
Section titled “Próximo passo”- Criar
ConversationGuardemsrc/contexts/agent/domain/services/ConversationGuard.tscom:- constantes
GUARD_RESPONSE_FRUSTRATED,GUARD_RESPONSE_LOOP,GUARD_RESPONSE_HUMAN_REQUEST(PT-BR); - regex
FRUSTRATION_REGEX+ helpercapsAbuse(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.
- constantes
- Estender
AgentChat.askemsrc/contexts/agent/domain/AgentChat.ts:- aceita
pastTurnsWithoutToolCall?: number(back-compat: opcional); - chama
ConversationGuard.evaluateantes do LLM; - se preempt, retorna
{reply: {role:"assistant", content: verdict.response}, toolCalls: []}direto.
- aceita
- Atualizar barrel
src/contexts/agent/domain/index.tsexportandoConversationGuard,GuardVerdict, e as três constantes. - Passar os 5 scenarios. Specs 008/012/013/021 continuam verdes sem touchup.