mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
4898 lines
229 KiB
TypeScript
4898 lines
229 KiB
TypeScript
import { prospectingConversationContext } from "@/lib/prospecting/context";
|
||
import { setExecutionAgentOperation } from '@/lib/atendimento/fronteira-server';
|
||
import { TIPOS_DE_CASO, TIPOS_DE_CASO_PARA_A_IA } from "@/lib/ai/case-copy";
|
||
import { DEFAULT_CHANNEL_PROVIDER } from '@/lib/channels/capabilities';
|
||
import { applyPreviewPolicy, previewGateContext, type TurnPreview } from './preview';
|
||
import { claimOfJob } from '../queue/claim';
|
||
import { currentExecutionBoundary, guardServiceEffect } from '@/lib/atendimento/fronteira-server';
|
||
/**
|
||
* Loop do agente v0 — handler do job `inbound_turn` (F2-09; blueprint 8.8).
|
||
*
|
||
* Cada job vira uma sessão FRESCA do motor LLM (via seam F2-23 — provider
|
||
* instanciado POR CHAMADA, nunca cache por lead em memória de processo): TODO o
|
||
* estado do run (seq de envio, outcomes, mensagens) vive no closure desta
|
||
* invocação — isolamento entre leads por construção (acceptance 3).
|
||
*
|
||
* Ritual imposto pelo RUNTIME, não pelo modelo:
|
||
* 1. abre lendo playbook (system, por ponteiro — F2-07) + checkpoint anterior de
|
||
* `lead_checkpoints` (compromissos/objeções/next_action + rolling summary) +
|
||
* `lead_state` (estágio do funil — F2-10) + últimas N mensagens via
|
||
* get_lead_context (F2-08);
|
||
* 2. o modelo decide tools livremente: `get_lead_context` (releitura),
|
||
* `send_message` — enviar é SEMPRE tool call (CLAUDE.md princípio 2); texto
|
||
* direto do modelo NUNCA vira mensagem (é descartado) — e `update_lead_state`
|
||
* (F2-10): o modelo MARCA avanços; a máquina de estados no código valida e o
|
||
* avanço é espelhado no CRM (crm_move_lead_stage); falha do espelho NÃO
|
||
* reverte o harness (fonte da verdade) — vira log + inbox_items;
|
||
* 3. fecha com uma 2ª chamada de modelo (purpose 'checkpoint') que devolve
|
||
* SOMENTE o JSON do checkpoint, validado por Zod e persistido — mecanismo
|
||
* escolhido por ser imposto pelo runtime (tool update_checkpoint dependeria
|
||
* de o modelo lembrar de chamá-la; a chamada de fechamento sempre acontece).
|
||
*
|
||
* Falhas: transporte/tool do CRM viram mensagem de ensino pro modelo no meio do
|
||
* run (padrão F2-08) E erro do job no fim (retry da fila com o ledger segurando
|
||
* duplicata); veto is_blocked cancela o job em definitivo (JobSettledError —
|
||
* main.ts não completa nem re-tenta). PII nunca entra em log/erro de job.
|
||
*/
|
||
import type pg from 'pg';
|
||
import { z } from 'zod';
|
||
import { auxModelArgs, type AuxModelArgs } from './aux-model-args';
|
||
import type { ChannelAdapter, ChannelSendResult } from '../channel-adapter';
|
||
|
||
import { withFields, type Logger } from '../obs/logger';
|
||
import {
|
||
corpoDaMensagem,
|
||
getLeadContext,
|
||
textoDoClienteNaUltimaMensagem,
|
||
type CorpoDaMensagemRow,
|
||
type LeadContext,
|
||
type LeadContextMessage,
|
||
type LeadContextResult,
|
||
} from '../edge/crm/get-lead-context';
|
||
import { citationsFromHits, searchKnowledge } from './search-knowledge';
|
||
import type { CrmEdgeConfig } from '../edge/crm/mcp-client';
|
||
import { WahaChannelAdapter } from '../edge/channel/waha-adapter';
|
||
// applySendOutcome é disposição de FILA (cancel/reschedule + cache de opt-out), não
|
||
// egress de canal — o envio em si vai pelo adapter (ChannelAdapter). Ver F2-25.
|
||
import { applySendOutcome } from '../edge/crm/send-message';
|
||
import {
|
||
LlmBudgetExceededError,
|
||
runModelCall,
|
||
tool,
|
||
type LlmEdgeConfig,
|
||
type ModelMessage,
|
||
type ToolSet,
|
||
} from '../edge/llm/run-model-call';
|
||
import type { ProviderRegistry } from '../edge/llm/providers';
|
||
import { HANDOFF_REASON_ORCAMENTO } from '../edge/llm/orcamento';
|
||
import { abreAvisoDoEspelhoRecusado, mirrorLeadStageToCrm } from '../edge/crm/move-lead-stage';
|
||
import { insertInboxItem } from '../db/repository';
|
||
import { createAdminClient } from '@/lib/supabase/admin';
|
||
import { moverLeadParaEtapaDeHandoff } from '@/lib/leads/handoff-stage-move';
|
||
import { detectUrgencySignal } from '../guardrails/sinal-de-urgencia';
|
||
import { buildNativeMediaParts } from './media-parts';
|
||
import {
|
||
copiarFotoNoStorage,
|
||
enviarComFotos,
|
||
prepararFotosDoProduto,
|
||
type FotoParaEnvio,
|
||
} from './fotos-do-produto';
|
||
import { enqueueJob, rescheduleJob, type JobRow, type Queryable } from '../queue/queue';
|
||
import {
|
||
applyLeadStateUpdate,
|
||
getLeadState,
|
||
type LeadStage,
|
||
type LeadStateRow,
|
||
} from './lead-state';
|
||
|
||
// ── Recorte 1 do #636 ────────────────────────────────────────────────────────
|
||
// O checkpoint e a abertura do turno moram em `./abertura/` desde este PR e
|
||
// voltam AQUI em reexport (bloco "Recorte 1 do #636", mais abaixo): o caminho
|
||
// legado `inbound-turn` é o que os testes e os irmãos buscam, e o grep deles
|
||
// não pode quebrar com a extração.
|
||
import {
|
||
CHECKPOINT_INSTRUCTION,
|
||
checkpointContentSchema,
|
||
insertCheckpoint,
|
||
parseCheckpointText,
|
||
type CheckpointContent,
|
||
type LeadCheckpointRow,
|
||
} from "./abertura/checkpoint";
|
||
import { buildOpeningMessage, ritualBlocks } from "./abertura/ritual";
|
||
import { applySaveLeadNote, buildNotesIndexBlock, getLeadNoteBody } from './lead-notes';
|
||
import { buildCompromissosBlock } from './compromissos-do-contato';
|
||
import { applyScheduleFollowup, type FollowupWindowKnobs } from './schedule-followup';
|
||
import { podeExporScheduleFollowup } from '@/lib/followup/callback-policy';
|
||
import {
|
||
avisarLeadDaEscalacao,
|
||
avisarLeadLendoOContato,
|
||
type AvisoDeEscalacaoIds,
|
||
type AvisoDeEscalacaoOpts,
|
||
type DesfechoDoAviso,
|
||
} from './aviso-de-escalacao';
|
||
import {
|
||
applyRequestHumanHandoff,
|
||
buildHandoffSummary,
|
||
detectAmbiguousOptOut,
|
||
detectHumanHandoffRequest,
|
||
isLeadInHandoff,
|
||
performHumanHandoff,
|
||
} from './human-handoff';
|
||
import {
|
||
maybeCompact,
|
||
renderCompactedSummary,
|
||
trimTranscriptToBudget,
|
||
type CompactionKnobs,
|
||
} from './compaction';
|
||
import { pruneToolResults, type PruneToolResultsKnobs } from './prune-tool-results';
|
||
import {
|
||
classifyStage,
|
||
recordStageDivergenceCandidate,
|
||
renderStageHint,
|
||
type StageClassifierKnobs,
|
||
} from './stage-classifier';
|
||
import { loadPlaybook } from './playbook';
|
||
import { promessasEmAberto } from './declaracao';
|
||
import {
|
||
projetarContexto,
|
||
projetarRetornoDeTool,
|
||
turnoProjeta,
|
||
type ContextoProjetado,
|
||
} from './projecao';
|
||
import {
|
||
capacidadesEntreguesAoOperador,
|
||
catalogoEntregueAoOperador,
|
||
} from './entrega-de-capacidade';
|
||
import { composeSystemPrompt, loadOrgMemory, renderOrgMemory } from './org-memory';
|
||
import { matchesHandoffKeyword, type PublishedAgentConfig } from './agent-config';
|
||
import { garantirPerguntaDoRoteiro, perguntaDoRoteiroPodeSair, prepararRoteiroDoTurno } from './roteiro-no-turno';
|
||
import { validarRespostaDoFluxo } from './flow-validate';
|
||
import { moduloLigadoComMemo } from '@/lib/instalacao/modulos';
|
||
import { msAteAJanelaAbrir } from './janela-de-atendimento';
|
||
import { janelaDeEnvioAberta, proximaAberturaDaJanela } from '../pacing/engine';
|
||
import { loadChannelKnobs } from '../pacing/store';
|
||
import { avisarJanelaFechada, resolverAvisoDeJanela } from '../pacing/aviso-de-janela';
|
||
import { resolveConversationTurn, type TurnAgentResolution } from './resolve-turn-agent';
|
||
import {
|
||
hasOpenCaseForContact,
|
||
getCaseAwaitingLead,
|
||
openCase,
|
||
provideCaseUpdate,
|
||
openHumanCaseInputSchema,
|
||
provideCaseUpdateInputSchema,
|
||
} from './human-cases';
|
||
import { buildMcpTurnTools } from '../edge/crm/mcp-tools';
|
||
import { definicaoNaConexao } from '@/lib/channels/linha-do-espelho';
|
||
import { cancelPendingCronsForLead } from '../cron/scheduler';
|
||
import {
|
||
latestInboundSignal,
|
||
recentInboundSignal,
|
||
loadSkills,
|
||
matchSkills,
|
||
recordSkillMissCandidates,
|
||
renderMatchedSkillBodies,
|
||
renderSkillIndex,
|
||
} from './skills';
|
||
import { readSkillReference, skillHasReferences } from './skill-references';
|
||
import { READ_ONLY_TOOLS, wrapToolsWithBreaker, type ToolBreakerThresholds } from './tool-breaker';
|
||
import { loadChannelProvider, nomesDasFerramentas, runBeforeSend } from '../guardrails/before-send';
|
||
import { isStatusSendable } from '../../channels/meta/template-binding';
|
||
import { capabilitiesOf } from '@/lib/channels/capabilities';
|
||
import { renderTemplateBody } from '@/lib/channels/meta/render-template';
|
||
import { acenderDigitando, esperarComoHumano } from './atraso-humano';
|
||
import { instrucaoDeBolhas, sendInBubbles, splitForSend } from './split-message';
|
||
import type { DisclosureMode } from '../guardrails/disclosure/template';
|
||
import { decidePromise } from '../guardrails/promise/engine';
|
||
import { loadPromiseTable } from '../guardrails/promise/table';
|
||
import { classifyPromise } from '../guardrails/promise/semantic';
|
||
import { expectativaDeAtendimento } from '@/lib/escalacao/disponibilidade';
|
||
import {
|
||
montarBriefingDaPassagem,
|
||
type BriefingDaPassagem,
|
||
} from '@/lib/escalacao/briefing-da-passagem';
|
||
import { diffCheckpoint } from '@/lib/leads/checkpoint-diff';
|
||
import { emitAgentActivityForContact } from '@/lib/leads/agent-activity';
|
||
import { resolveActiveLeadForContact, type LeadCandidate } from '@/lib/leads/active-lead';
|
||
import { recalculaScoreDoLead } from '@/lib/leads/score-writer';
|
||
import {
|
||
JAILBREAK_ESCALATION_LEVEL,
|
||
classifyJailbreak,
|
||
escalateJailbreakPromise,
|
||
type JailbreakClassifierKnobs,
|
||
type JailbreakLevel,
|
||
} from '../guardrails/jailbreak/classifier';
|
||
import { camadaLigada, lerCamadasDaOrg } from '../guardrails/camadas-da-org';
|
||
import {
|
||
nivelFinalDaManipulacao,
|
||
perguntarManipulacaoAoJev,
|
||
registrarManipulacaoDoJev,
|
||
type ManipulacaoDoJev,
|
||
} from '@/lib/ai/decisao/manipulacao';
|
||
import type { DependenciasDoPonto } from '@/lib/ai/decisao/ponto';
|
||
import { fusoDaOrganizacao } from './fuso-da-org';
|
||
import { renderAgora } from '@/lib/tempo/agora';
|
||
import { decidirElegibilidadeDaConversa } from '@/lib/ai/elegibilidade/consulta-pg';
|
||
import {
|
||
anotarUltimaInboundVista,
|
||
respostaFicouObsoleta,
|
||
ultimaInboundJaRespondida,
|
||
} from './turno-ja-respondido';
|
||
|
||
/**
|
||
* Superfície ESTÁTICA das tools do agente (description + inputSchema) — parte do
|
||
* prefixo estável de cache (F2-17). Única fonte: o handler monta as tools reais
|
||
* daqui (+ execute do closure) e `scripts/ops-count-prefix.ts` mede o prefixo
|
||
* real sem precisar de um run. Nada volátil entra aqui, por construção.
|
||
*/
|
||
export const AGENT_TOOL_DEFS = {
|
||
get_lead_context: {
|
||
description:
|
||
'Relê o contexto curado do lead nesta organização: dados do contato e as últimas mensagens da conversa.',
|
||
inputSchema: z.object({}),
|
||
},
|
||
send_message: {
|
||
description:
|
||
'Envia UMA mensagem de WhatsApp ao lead desta conversa. É o ÚNICO jeito de falar com o lead; texto fora desta tool nunca é enviado.',
|
||
inputSchema: z.object({
|
||
body: z.string().min(1).describe('corpo da mensagem, em pt-br, pronto para envio'),
|
||
produto_codigo: z
|
||
.string()
|
||
.optional()
|
||
.describe(
|
||
'código de um produto do catálogo (o `codigo` de crm_search_products) que tem `fotos`: ' +
|
||
'as fotos dele vão junto, e o texto vira a legenda da primeira',
|
||
),
|
||
}),
|
||
},
|
||
update_lead_state: {
|
||
description:
|
||
'Marca um avanço REAL no funil deste lead: stage (new → contacted → qualifying → qualified → ' +
|
||
'negotiating → won | lost; só o PRÓXIMO estágio válido — regressão é rejeitada), qualification ' +
|
||
'(budget/authority/need/timeline), next_action e reason (evidência curta do avanço). ' +
|
||
'Nunca invente avanço sem evidência na conversa.',
|
||
// Schema LARGO só para o SDK (o modelo vê os campos); a validação REAL é a
|
||
// whitelist .strict() dentro de applyLeadStateUpdate — campo extra/forjado
|
||
// vira erro de ENSINO ao modelo, nunca exceção do SDK nem strip silencioso.
|
||
inputSchema: z
|
||
.object({
|
||
stage: z.string().optional().describe('novo estágio do funil (só o próximo válido)'),
|
||
qualification: z
|
||
.object({})
|
||
.passthrough()
|
||
.optional()
|
||
.describe('qualificação: budget, authority, need, timeline'),
|
||
next_action: z
|
||
.string()
|
||
.nullable()
|
||
.optional()
|
||
.describe('próxima ação concreta combinada com o lead'),
|
||
reason: z.string().optional().describe('evidência curta do avanço (vai ao audit do CRM)'),
|
||
})
|
||
.passthrough(),
|
||
},
|
||
schedule_followup: {
|
||
description:
|
||
'Agenda o SEU próprio retorno a este lead num momento futuro (follow-up). Use sempre que ' +
|
||
'prometer voltar a falar depois (ex.: "te retorno amanhã de manhã", "confirmo na segunda"). ' +
|
||
'Um agendamento por promessa; o sistema fará o follow-up sozinho no horário combinado — ' +
|
||
'depois de agendar, encerre o turno.',
|
||
// Schema LARGO para o SDK (o modelo vê os campos); a validação REAL é a whitelist
|
||
// .strict() + guard de prototype pollution dentro de applyScheduleFollowup — campo
|
||
// extra/forjado e data inválida viram erro de ENSINO ao modelo, nunca exceção do SDK.
|
||
inputSchema: z
|
||
.object({
|
||
reason: z.string().describe('por que agendar o retorno'),
|
||
promised_at: z
|
||
.string()
|
||
.describe('data/hora ISO 8601 do retorno (no futuro), ex.: "2026-07-15T14:00:00Z"'),
|
||
promise: z.string().describe('o que você prometeu ao lead'),
|
||
context_snapshot: z
|
||
.string()
|
||
.nullable()
|
||
.optional()
|
||
.describe('contexto curto para o seu run futuro'),
|
||
})
|
||
.passthrough(),
|
||
},
|
||
save_lead_note: {
|
||
description:
|
||
'Salva uma nota DURÁVEL na memória deste lead (persiste entre conversas). Use para fatos que ' +
|
||
'você vai querer lembrar depois: preferências, contexto pessoal, restrições, o que já foi ' +
|
||
'oferecido. A headline (linha curta) entra sempre no índice de memória do lead; o corpo completo ' +
|
||
'fica guardado e você o relê sob demanda com get_lead_note. Para CONSOLIDAR notas antigas, ' +
|
||
'liste os ids delas em "supersedes" (você os vê no índice) — elas são removidas ao salvar a nova.',
|
||
// Schema LARGO para o SDK (o modelo vê os campos); a validação REAL é a whitelist
|
||
// .strict() + guard de prototype pollution dentro de applySaveLeadNote — campo
|
||
// extra/forjado vira erro de ENSINO ao modelo, nunca exceção do SDK nem strip silencioso.
|
||
inputSchema: z
|
||
.object({
|
||
headline: z.string().describe('linha curta do índice (sempre visível no prompt)'),
|
||
body: z.string().describe('corpo completo da nota (lido sob demanda por get_lead_note)'),
|
||
supersedes: z
|
||
.array(z.string())
|
||
.optional()
|
||
.describe('ids de notas que esta substitui/consolida (vistos no índice de memória)'),
|
||
})
|
||
.passthrough(),
|
||
},
|
||
get_lead_note: {
|
||
description:
|
||
'Lê o CORPO completo de UMA nota da memória deste lead pelo id (o id aparece no índice de memória, ' +
|
||
'entre colchetes). Use quando a headline no índice não bastar e você precisar do detalhe.',
|
||
inputSchema: z
|
||
.object({
|
||
note_id: z.string().describe('id da nota (como aparece no índice, entre colchetes)'),
|
||
})
|
||
.passthrough(),
|
||
},
|
||
search_knowledge: {
|
||
description:
|
||
'Busca na BASE DE CONHECIMENTO da organização (FAQ, políticas, catálogo) os trechos mais ' +
|
||
'relevantes para uma pergunta. Use ANTES de responder qualquer dúvida factual sobre produto, ' +
|
||
'preço, prazo, política ou funcionamento — responda com base nos trechos retornados e não ' +
|
||
'invente o que não encontrar. Sem resultados = diga que vai confirmar, nunca chute.',
|
||
inputSchema: z
|
||
.object({
|
||
query: z.string().min(2).describe('a pergunta ou termos a buscar, em pt-br'),
|
||
})
|
||
.passthrough(),
|
||
},
|
||
request_human_handoff: {
|
||
description:
|
||
'Passa a conversa para um ATENDENTE HUMANO imediatamente. Use quando o lead pedir para falar com ' +
|
||
'uma pessoa, quando a situação exigir alguém humano (reclamação séria, questão jurídica/financeira ' +
|
||
'sensível) ou quando você atingir o limite do que pode resolver. ' +
|
||
'AVISE O LEAD ANTES: mande uma mensagem dizendo que você vai chamar alguém da equipe e SÓ ENTÃO ' +
|
||
'chame esta ferramenta — depois dela você não consegue mais falar com ele. Se você não avisar, ' +
|
||
'o sistema manda um aviso padrão no seu lugar. Acionada a ferramenta, encerre o turno. ' +
|
||
'NUNCA diga ao lead que "já chamei alguém" ou "já passei para a equipe" sem ter chamado esta ' +
|
||
'ferramenta NO MESMO turno — a frase no passado não substitui a ação, e ninguém é avisado de verdade. ' +
|
||
'Preencha por_que, o_que_tentei e cliente_quer — quem assumir só vê o que você escrever aqui.',
|
||
// Schema LARGO para o SDK (o modelo vê o campo); a validação REAL é a whitelist .strict()
|
||
// + guard de prototype pollution dentro de applyRequestHumanHandoff — campo extra/forjado
|
||
// vira erro de ENSINO ao modelo, nunca exceção do SDK nem strip silencioso.
|
||
//
|
||
// ⚠️ ESPELHO: as chaves aqui e as de `requestHumanHandoffInputSchema`
|
||
// (`human-handoff.ts`) são o MESMO conjunto, e
|
||
// `tests/unit/passagem-tool-schema-espelhado.test.ts` as compara. Campo só
|
||
// deste lado = o modelo preenche e a whitelist recusa, virando erro de
|
||
// ensino a cada chamada; campo só do outro = o modelo nunca sabe que existe.
|
||
//
|
||
// Os `.describe()` são o ÚNICO lugar onde o modelo aprende o que escrever, e
|
||
// é por isso que eles trazem exemplo em vez de definição.
|
||
inputSchema: z
|
||
.object({
|
||
por_que: z
|
||
.string()
|
||
.optional()
|
||
.describe(
|
||
'em uma frase, por que você não consegue resolver e está passando para uma pessoa',
|
||
),
|
||
o_que_tentei: z
|
||
.array(
|
||
z.object({
|
||
o_que: z
|
||
.string()
|
||
.describe('o que você tentou (ex.: "busquei na base a política de desconto")'),
|
||
desfecho: z.string().optional().describe('no que deu (ex.: "a política só vai até 10%")'),
|
||
}),
|
||
)
|
||
.optional()
|
||
.describe('o que você já tentou, na ordem — evita que a pessoa refaça o mesmo caminho'),
|
||
cliente_quer: z
|
||
.string()
|
||
.optional()
|
||
.describe('o que a pessoa está pedindo, nas palavras dela'),
|
||
reason: z.string().optional().describe('sinônimo antigo de por_que (ainda aceito)'),
|
||
})
|
||
.passthrough(),
|
||
},
|
||
read_skill_reference: {
|
||
description:
|
||
'Lê o conteúdo de UMA reference (arquivo de apoio) do pacote de uma skill situacional que já ' +
|
||
'CASOU neste turno. Use quando o corpo da skill ativa mencionar uma reference e você precisar do ' +
|
||
'detalhe completo dela. Só funciona para skills ativas AGORA — pedir skill não ativa ou caminho ' +
|
||
'fora do manifesto dela volta erro.',
|
||
inputSchema: z
|
||
.object({
|
||
skill_name: z
|
||
.string()
|
||
.min(1)
|
||
.describe('nome da skill ativa neste turno (como aparece no bloco de skills)'),
|
||
ref_path: z.string().min(1).describe('caminho da reference dentro do pacote da skill'),
|
||
})
|
||
.passthrough(),
|
||
},
|
||
open_human_case: {
|
||
description:
|
||
'Abra um caso para um humano de retaguarda quando você NÃO conseguir resolver o pedido do lead ' +
|
||
'sozinho (liberar acesso, corrigir algo num sistema, uma decisão que exige uma pessoa). Você CONTINUA ' +
|
||
'conversando com o lead normalmente — não silencia. Use SEMPRE que for prometer ao lead que alguém vai ' +
|
||
'verificar/resolver: prometer sem abrir o caso é proibido. Isso vale mesmo quando você nomeia a ' +
|
||
'pessoa ("vou confirmar com o Fulano", "já registrei com a equipe") — nomear alguém não abre o caso; ' +
|
||
'só esta ferramenta abre. Chame-a NO MESMO turno em que fizer a promessa, nunca depois.',
|
||
// Schema LARGO para o SDK (o modelo vê os campos); a validação REAL é a whitelist
|
||
// .strict() openHumanCaseInputSchema (human-cases.ts) — campo extra/forjado vira
|
||
// erro de ENSINO ao modelo, nunca exceção do SDK nem strip silencioso.
|
||
inputSchema: z
|
||
.object({
|
||
title: z.string().describe('título curto, ex.: "Liberar acesso ao painel"'),
|
||
summary: z.string().describe('o que o lead precisa, em pt-br'),
|
||
blocker: z.string().describe('por que você não consegue resolver sozinho'),
|
||
// O assunto serve para quem TRIA a fila separar antes de ler. O detalhe
|
||
// continua no título e no resumo — este campo não os substitui, e por
|
||
// isso a lista é curta: muitas opções produzem classificação
|
||
// inconsistente, e aí o filtro atrapalha em vez de ajudar.
|
||
kind: z
|
||
.enum(Object.keys(TIPOS_DE_CASO) as [string, ...string[]])
|
||
.describe(
|
||
'do que o caso trata, para a equipe triar: ' +
|
||
Object.entries(TIPOS_DE_CASO_PARA_A_IA)
|
||
.map(([k, o]) => `${k} (${o})`)
|
||
.join('; ') +
|
||
'. Na dúvida entre dois, escolha o que descreve o PEDIDO, não o obstáculo.',
|
||
),
|
||
})
|
||
.passthrough(),
|
||
},
|
||
provide_case_update: {
|
||
description:
|
||
'Quando um caso está esperando informação do cliente e você já colheu essa informação na conversa, ' +
|
||
'use esta tool para devolver a informação ao humano responsável. Não invente — só o que o lead disse.',
|
||
// Schema LARGO para o SDK; a validação REAL é a whitelist .strict()
|
||
// provideCaseUpdateInputSchema (human-cases.ts).
|
||
inputSchema: z
|
||
.object({
|
||
case_id: z.string().describe('id do caso aberto'),
|
||
info: z.string().describe('a informação colhida do lead'),
|
||
})
|
||
.passthrough(),
|
||
},
|
||
send_template: {
|
||
description:
|
||
'Envia um TEMPLATE aprovado do WhatsApp. Use SOMENTE quando o send_message for recusado ' +
|
||
'porque a janela de 24 horas com o contato fechou — a mensagem de erro diz quando é o caso. ' +
|
||
'Você precisa do nome exato do template, do idioma e de um valor para CADA parâmetro. ' +
|
||
'Se faltar valor, a resposta diz quais e você pode chamar de novo; qualquer outro erro ' +
|
||
'significa que um humano precisa agir — encerre o turno sem insistir.',
|
||
inputSchema: z
|
||
.object({
|
||
template_name: z.string().min(1).describe('nome exato do template, como aprovado na Meta'),
|
||
language: z.string().min(2).describe('código do idioma, ex.: pt_BR'),
|
||
values: z
|
||
.record(z.string(), z.string())
|
||
.describe(
|
||
'valor de cada parâmetro, na chave que a tela de templates mostra (ex.: "1", "2")',
|
||
),
|
||
})
|
||
.passthrough(),
|
||
},
|
||
} as const;
|
||
|
||
/**
|
||
* Quantos vetos de `internal_vocabulary_leak` o turno tolera antes de o fail-safe soltar
|
||
* o envio (ver o bloco em `send_message.execute`). Mesmo degrau do fail-safe de casos
|
||
* humanos — 1ª vez ensina, a 2ª decide — porque a assimetria é a mesma: uma reescrita
|
||
* que o modelo não fez não vale um cliente sem resposta.
|
||
*/
|
||
export const MAX_VETOS_DE_VOCABULARIO_INTERNO = 2;
|
||
|
||
/**
|
||
* O mesmo degrau para o veto de `false_empty_inbound`, e pela mesma assimetria.
|
||
*
|
||
* Sem teto, o contador só subia: um falso positivo teimoso da detecção calava o
|
||
* turno INTEIRO — o cliente ficava sem resposta por causa de uma frase nossa,
|
||
* não de uma frase dele. Medido no regex desta entrega, num corpus de 6 frases
|
||
* legítimas de atendimento, 1 disparava o veto. Uma barreira de conteúdo que
|
||
* não sabe desistir troca um erro visível (a frase falsa) por um invisível (o
|
||
* silêncio), e o invisível é pior: ninguém o percebe do lado de cá.
|
||
*
|
||
* Soltar NÃO é soltar calado — o fail-safe registra (`runLog.warn`), que é o
|
||
* laço de retorno: o turno em que a barreira errou fica legível depois.
|
||
*/
|
||
export const MAX_VETOS_DE_FALSO_VAZIO = 2;
|
||
|
||
/**
|
||
* Teto de mensagens FÍSICAS enviadas ao lead por turno quando `knobs.maxSendsPerTurn`
|
||
* está ausente (testes) — produção sempre recebe o knob do env (MAX_SENDS_PER_TURN).
|
||
*
|
||
* Existe porque NENHUM gate de before-send limita CONTAGEM por turno — `pacing` só
|
||
* limita RITMO (tempo entre envios), não quantidade. Sem este teto, um modelo que
|
||
* decida tratar uma lista de perguntas de qualificação como uma mensagem por pergunta
|
||
* (em vez de perguntar uma e esperar a resposta) só para no teto genérico de STEPS do
|
||
* loop de tools (AGENT_MAX_STEPS) — e esse teto conta QUALQUER tool, não só envio.
|
||
* Medido em produção: um lead recebeu 8 mensagens seguidas do mesmo turno.
|
||
*/
|
||
export const DEFAULT_MAX_SENDS_PER_TURN = 3;
|
||
|
||
/**
|
||
* Job já saiu de 'running' por decisão do próprio run (ex.: cancelJob no veto
|
||
* is_blocked) — o worker NÃO deve completar nem re-tentar. main.ts trata via
|
||
* failJob, que no-opa (lease já não é dele) — estado final é o que o run deixou.
|
||
*/
|
||
export class JobSettledError extends Error {
|
||
override readonly name = 'job_settled';
|
||
}
|
||
|
||
// Shape que o drain (F2-05) grava no payload do job — organization/lead vêm da
|
||
// ROW do job (fonte confiável), nunca daqui; o payload só carrega ponteiros do CRM.
|
||
const inboundTurnPayloadSchema = z
|
||
.object({
|
||
conversation_id: z.string().uuid(),
|
||
contact_id: z.string().uuid(),
|
||
channel_session_id: z.string().uuid(),
|
||
inbound_message_id: z.string().uuid(),
|
||
crm_event_id: z.string().uuid(),
|
||
})
|
||
.passthrough();
|
||
|
||
/**
|
||
* O evento já traz o id exato da mensagem que acordou o agente. Ler o "último
|
||
* inbound" da conversa novamente abre uma corrida: outro evento do canal pode
|
||
* entrar entre o despacho e o turno, e o agente passa a responder ao registro
|
||
* errado. A resposta deve sempre usar esta linha canônica.
|
||
*
|
||
* ⚠️ Ela é COMPOSTA pelo mesmo caminho do histórico (`corpoDaMensagem`), nunca
|
||
* pela coluna `body` crua. Áudio e foto chegam do WhatsApp sem legenda — `body`
|
||
* NULL e o conteúdo no derivado (transcrição/visão, gravado DEPOIS pelo
|
||
* `workers/media-derive-worker.ts`: é essa a corrida que se mede aqui) ou no
|
||
* marcador `[tipo]`. Lida crua, a linha canônica valia `''` enquanto o histórico
|
||
* logo abaixo mostrava o texto do cliente — e os DOIS lados do defeito saem
|
||
* daqui: a abertura anunciava "não há texto utilizável" sobre uma mensagem que
|
||
* tem texto, e a barreira do falso-vazio desarmava, porque
|
||
* `claimsCurrentInboundIsEmpty` devolve `false` quando o texto canônico é `''`.
|
||
* (issue #617)
|
||
*
|
||
* Exportada só para o teste: o recorte (org + conversa + id + `direction`) é o
|
||
* que impede um id de outra conversa — ou uma outbound — de virar "a mensagem
|
||
* atual", e um recorte não se prova lendo a chamada.
|
||
*/
|
||
export async function loadInboundBodyForJob(
|
||
db: Queryable,
|
||
input: { tenantId: string; conversationId: string; inboundMessageId: string },
|
||
): Promise<string | null> {
|
||
const result = await db.query<CorpoDaMensagemRow>(
|
||
`select type, body, media_url, media_storage_path, media_derived_text
|
||
from messages
|
||
where organization_id = $1
|
||
and conversation_id = $2
|
||
and id = $3
|
||
and direction = 'inbound'
|
||
limit 1`,
|
||
[input.tenantId, input.conversationId, input.inboundMessageId],
|
||
);
|
||
const row = result.rows[0];
|
||
return row === undefined ? null : corpoDaMensagem(row);
|
||
}
|
||
|
||
/**
|
||
* Recorte 1 do #636: o checkpoint (schema, ROW, instrução do fechamento,
|
||
* gravação e parse) e a abertura (`ritualBlocks` + `buildOpeningMessage`) moram
|
||
* em `./abertura/` desde este PR — este arquivo orquestra.
|
||
*
|
||
* O reexport AQUI não é enfeite: é por `inbound-turn` que os testes e os irmãos
|
||
* (follow-up, resposta de caso, retomada da escalação) buscam estes símbolos, e
|
||
* o grep deles tem de continuar achando. Trocar os chamadores de lugar é
|
||
* recorte posterior — não este.
|
||
*/
|
||
export {
|
||
CHECKPOINT_INSTRUCTION,
|
||
checkpointContentSchema,
|
||
parseCheckpointText,
|
||
ritualBlocks,
|
||
buildOpeningMessage,
|
||
};
|
||
export type { CheckpointContent, LeadCheckpointRow };
|
||
|
||
/**
|
||
* Reexportado do módulo puro, onde ele PRECISA morar: o caminho legado
|
||
* (`workers/ai-response-worker.ts`) grava a mesma razão e não pode importar este
|
||
* arquivo. Fica visível aqui porque é daqui que o engine a grava.
|
||
*/
|
||
export { HANDOFF_REASON_ORCAMENTO };
|
||
|
||
/**
|
||
* Primeira linha do resumo que vai ao humano quando o orçamento interrompe o
|
||
* turno. É TEXTO FIXO, e tem de ser: o desvio existe porque não há orçamento
|
||
* para chamar o modelo, então gerar este resumo por LLM seria gastar exatamente
|
||
* o que acabou de ser recusado. O contexto útil vem logo abaixo, do checkpoint
|
||
* durável (`buildHandoffSummary`), que também não custa token nenhum.
|
||
*/
|
||
export const RESUMO_DO_HANDOFF_POR_ORCAMENTO =
|
||
'A IA parou de responder porque o teto de gasto mensal com IA desta organização foi ' +
|
||
'atingido — o lead NÃO pediu atendimento humano. Assuma a conversa; para devolvê-la ao ' +
|
||
'atendimento automático, ajuste o teto em Uso de IA › Orçamento e use "Devolver ao ' +
|
||
'automático" no cabeçalho da conversa.';
|
||
|
||
/** Título do item da Central que este handoff abre — rótulo visível, logo constante. */
|
||
export const TITULO_DO_HANDOFF_POR_ORCAMENTO = 'Teto de gasto com IA atingido — assumir a conversa';
|
||
|
||
/**
|
||
* ORÇAMENTO ESGOTADO NÃO PODE VIRAR SILÊNCIO PARA O LEAD.
|
||
*
|
||
* `aplicarOrcamento` recusa a chamada ANTES de sair byte para o provedor
|
||
* (`../edge/llm/run-model-call.ts`), e a exceção subia direto para o `catch` do
|
||
* worker. Do lado de fora, no WhatsApp, isso é uma pessoa que perguntou alguma
|
||
* coisa e não recebeu resposta nenhuma — nem da IA, nem de gente. A proteção que
|
||
* existe para salvar dinheiro quebrava o invariante 4 da doutrina do Sistema
|
||
* Vivo: nenhuma demanda sem próximo passo.
|
||
*
|
||
* A resposta certa já existe no repositório e é feita exatamente para isto:
|
||
* `performHumanHandoff` transiciona a conversa `ai_handling`→`pending` (fila
|
||
* humana), silencia o bot, cancela os follow-ups agendados do lead e abre um
|
||
* `agent_inbox_items` kind `handoff` — TUDO em banco, SEM GASTAR UM TOKEN, o que
|
||
* aqui não é detalhe: o motivo do desvio é justamente não haver orçamento. Por
|
||
* isso o resumo é texto fixo mais o checkpoint durável, nunca um resumo gerado.
|
||
*
|
||
* RELANÇA sempre. Quem decide o destino do job é a fila
|
||
* (`workers/agent-worker/main.ts` manda erro terminal para `cancelJob`, não para
|
||
* `failJob`). Engolir aqui trocaria uma falha visível por uma silenciosa, e pior:
|
||
* o turno seguiria para o fechamento como se o modelo tivesse respondido.
|
||
*
|
||
* Se o PRÓPRIO handoff falhar (banco fora), a exceção DELE é que sobe — e é o
|
||
* comportamento certo: ela não é terminal, então o job re-tenta e o handoff volta
|
||
* a ser tentado. Preservar o erro de orçamento aqui faria o job ser cancelado com
|
||
* o lead ainda no vácuo, que é o defeito que esta função existe para fechar.
|
||
*
|
||
* É função de módulo, e não closure do turno, para poder ser exercitada sozinha:
|
||
* o caminho de erro de um turno de agente é caro demais para se provar só de
|
||
* ponta a ponta, e o que precisa ser provado aqui é pequeno e exato.
|
||
*
|
||
* ═══ POR QUE ELA ENVOLVE O TURNO INTEIRO, E NÃO AS CHAMADAS DE MODELO ═══
|
||
*
|
||
* A primeira versão envolvia as DUAS chamadas diretas de `runModelCall` do
|
||
* turno. Estava errada, e do jeito mais silencioso possível: o turno faz outras
|
||
* chamadas de modelo ANTES delas, por funções auxiliares —
|
||
* `classifyStage` (`stage-classifier.ts`, purpose `stage_classifier`, roda em
|
||
* TODO turno porque `main.ts` monta `stageClassifier: {…}` como literal de
|
||
* objeto, sempre definido) e `maybeCompact`/flush (`compaction.ts`, purposes
|
||
* `compaction`/`flush`). Nenhum desses purposes está em `PURPOSES_ISENTOS`, e
|
||
* nenhum tinha try/catch: com o teto estourado, o erro subia do classificador
|
||
* ANTES de a escolta existir, o handoff NUNCA rodava, e o worker — que lê
|
||
* `terminal` e chama `cancelJob` — descartava o job. Lead no vácuo, sem retry,
|
||
* sem alerta. A escolta cobria o caso raro e faltava no dominante.
|
||
*
|
||
* Envolver o turno inteiro é o único desenho que não envelhece: não há lista de
|
||
* auxiliares a manter, e o auxiliar que alguém acrescentar amanhã já nasce
|
||
* coberto. `briefingDoCheckpoint` é uma FUNÇÃO resolvida dentro do catch (e não um
|
||
* valor pronto), porque no caminho novo a escolta abre antes de o checkpoint ter
|
||
* sido lido — e ler o checkpoint no caminho feliz seria uma query a mais por
|
||
* turno para um texto que quase nunca é usado.
|
||
*/
|
||
export async function comHandoffSeOrcamentoAcabar<T>(
|
||
ctx: {
|
||
pool: pg.Pool;
|
||
tenantId: string;
|
||
leadId: string;
|
||
conversationId: string;
|
||
/**
|
||
* Resolvido SÓ no caminho de erro: montado do checkpoint durável, zero LLM.
|
||
*
|
||
* Devolve o BRIEFING inteiro, e não só o texto, porque a linha da passagem
|
||
* guarda as quatro colunas que ele carrega. Um campo, um significado: "o
|
||
* contexto que vai para quem assume".
|
||
*/
|
||
briefingDoCheckpoint: () => Promise<BriefingDaPassagem>;
|
||
/**
|
||
* Avisa o lead de que uma pessoa vai assumir, ANTES do handoff.
|
||
*
|
||
* Também resolvido só no caminho de erro, e pela mesma razão do resumo: o
|
||
* caminho feliz não deve pagar por nada disto. O aviso é texto de CÓDIGO,
|
||
* então não gasta um token — o que aqui não é detalhe, é o único jeito de
|
||
* ele existir: o motivo do desvio é justamente não haver mais orçamento.
|
||
*/
|
||
avisarLead: () => Promise<DesfechoDoAviso>;
|
||
log: Logger;
|
||
},
|
||
chamada: () => Promise<T>,
|
||
): Promise<T> {
|
||
try {
|
||
return await chamada();
|
||
} catch (err) {
|
||
if (!(err instanceof LlmBudgetExceededError)) throw err;
|
||
const doCheckpoint = await ctx.briefingDoCheckpoint();
|
||
// AVISA antes de silenciar — ver a nota de ORDEM no gatilho determinístico:
|
||
// `performHumanHandoff` arma a trava que o gate de envio lê, então a única
|
||
// janela em que o aviso passa é ANTES dela.
|
||
//
|
||
// O try/catch NÃO é defesa contra o emissor de hoje (`avisarLeadLendoOContato`
|
||
// já promete não lançar) — é contra a dependência que a ordem cria. Sem ele,
|
||
// um canal fora do ar faria o cliente perder o aviso E o atendente, quando o
|
||
// pior dos dois já teria acontecido no primeiro. Medido por
|
||
// `tests/unit/handoff-por-orcamento.test.ts` ("aviso que falha NÃO impede a
|
||
// passagem"), que reprovava a versão anterior desta linha.
|
||
let aviso: DesfechoDoAviso;
|
||
try {
|
||
aviso = await ctx.avisarLead();
|
||
} catch (erroDoAviso) {
|
||
ctx.log.warn('aviso ao lead falhou antes da passagem por orçamento', {
|
||
error:
|
||
erroDoAviso instanceof Error ? erroDoAviso.message.slice(0, 200) : 'erro desconhecido',
|
||
});
|
||
aviso = { avisado: false, porque: 'erro_no_envio' };
|
||
}
|
||
// O texto fixo fica NA FRENTE do contexto acumulado, como antes: ele é o que
|
||
// diz a quem assume que o cliente NÃO pediu uma pessoa — sem isso o
|
||
// atendente responde a um pedido que não houve. Nenhum modelo é chamado
|
||
// aqui, e é o ponto: o motivo do desvio é justamente não haver orçamento.
|
||
const briefing: BriefingDaPassagem = {
|
||
...doCheckpoint,
|
||
body: `${RESUMO_DO_HANDOFF_POR_ORCAMENTO}\n\n${doCheckpoint.body}`,
|
||
};
|
||
await performHumanHandoff(
|
||
ctx.pool,
|
||
{ tenantId: ctx.tenantId, leadId: ctx.leadId, conversationId: ctx.conversationId },
|
||
{
|
||
reason: HANDOFF_REASON_ORCAMENTO,
|
||
conversationSummary: briefing.body,
|
||
inboxTitle: TITULO_DO_HANDOFF_POR_ORCAMENTO,
|
||
passagem: { origem: 'teto_de_gasto', motivoCodigo: 'orcamento_de_ia', briefing },
|
||
avisoAoLead: aviso,
|
||
log: ctx.log,
|
||
},
|
||
);
|
||
ctx.log.warn('turno interrompido pelo teto de gasto — conversa devolvida à fila humana', {
|
||
lead_avisado: aviso.avisado,
|
||
});
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* O resumo que vai ao humano quando o orçamento interrompe o turno, lido do
|
||
* checkpoint durável. Falhar aqui NÃO pode impedir o handoff: sem resumo o
|
||
* humano assume com menos contexto; sem handoff ele não assume nada.
|
||
*/
|
||
async function briefingDoCheckpointDuravel(
|
||
pool: pg.Pool,
|
||
tenantId: string,
|
||
leadId: string,
|
||
log: Logger,
|
||
): Promise<BriefingDaPassagem> {
|
||
const montar = (checkpoint: Awaited<ReturnType<typeof latestCheckpoint>>) =>
|
||
montarBriefingDaPassagem({ checkpoint, motivo: { codigo: 'orcamento_de_ia' } });
|
||
try {
|
||
return montar(await latestCheckpoint(pool, tenantId, leadId));
|
||
} catch (err) {
|
||
log.warn('resumo do checkpoint não pôde ser lido — o handoff segue sem ele', {
|
||
error: (err instanceof Error ? err.message : String(err)).slice(0, 120),
|
||
});
|
||
return montar(null);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bloco de sistema RESIDENTE das tools de caso (spec 15 §5.2) — entra no prefixo
|
||
* cacheável junto do índice de skills quando `casesEnabled`, pra não sumir em
|
||
* conversa longa (ao contrário do índice de skills, este bloco não some).
|
||
*/
|
||
const CASES_SYSTEM_BLOCK =
|
||
'## Casos para um humano de retaguarda\n' +
|
||
'Quando você NÃO conseguir resolver o pedido do lead sozinho (liberar acesso, corrigir algo num ' +
|
||
'sistema, uma decisão que exige uma pessoa), use a tool open_human_case — você CONTINUA conversando ' +
|
||
'com o lead, não silencia. NUNCA prometa ao lead que um humano vai verificar/resolver sem antes chamar ' +
|
||
'open_human_case. Quando um caso estiver esperando informação do cliente e você já a obteve na ' +
|
||
'conversa, use provide_case_update para devolver ao responsável. Ao avisar o lead que abriu o caso, ' +
|
||
'NUNCA narre a causa técnica ou interna (erro de sistema, falha de confirmação, nome de ferramenta, ' +
|
||
'log ou qualquer diagnóstico) — isso é assunto técnico e não vai pro cliente. `title`/`summary`/`blocker` ' +
|
||
'são só para o humano; a mensagem ao lead diz apenas, em linguagem simples, que você vai verificar/ajustar ' +
|
||
'e volta com uma resposta, sem explicar o motivo interno.';
|
||
|
||
/**
|
||
* Bloco de sistema RESIDENTE de transparência — SEMPRE presente, independente de
|
||
* `casesEnabled` ou de `open_human_case` ter sido chamado neste turno.
|
||
*
|
||
* Por quê: `CASES_SYSTEM_BLOCK` só ensina a não narrar a causa técnica NO MOMENTO de
|
||
* abrir um caso — mas o modelo narra "problema no sistema" também SEM abrir caso
|
||
* nenhum, quando só está incerto ou algo falhou silenciosamente (medido em produção,
|
||
* 2026-08-29: "houve um pequeno problema no sistema sobre o agendamento", mandado ao
|
||
* cliente às 11:43, sem nenhum `agent_cases` aberto naquele turno — o veto de
|
||
* `CASES_SYSTEM_BLOCK` nunca chegou a valer porque a tool nunca foi chamada). O
|
||
* detector de vazamento (`vazamento-interno.ts`) não pega isso por desenho — ele caça
|
||
* FORMA (identificador técnico), não sentença comum em português — então a única
|
||
* cura possível aqui é instrução, não filtro.
|
||
*/
|
||
const TRANSPARENCIA_SYSTEM_BLOCK =
|
||
'## Nunca narre problema interno ao lead\n' +
|
||
'Em QUALQUER mensagem — abrindo caso ou não — NUNCA diga ao lead que "houve um problema/erro no ' +
|
||
'sistema", "falha na confirmação", "erro técnico" ou qualquer variação que admita que algo deu errado ' +
|
||
'do lado interno. Isso vale mesmo quando você está incerto do resultado de uma ferramenta ou algo ' +
|
||
'falhou sem você entender o motivo. O lead não precisa do diagnóstico, precisa saber o que fazer ' +
|
||
'agora: diga que vai verificar/confirmar e volta com a resposta, peça mais um instante, ou pergunte de ' +
|
||
'novo o que falta — nunca admita que "o sistema" ou "a confirmação" teve um problema.';
|
||
|
||
/**
|
||
* Bloco de sistema RESIDENTE da Agenda — entra no prefixo cacheável sempre que o
|
||
* agente tem `crm_book_appointment` no `tool_ids` publicado, INDEPENDENTE de a skill
|
||
* situacional "agendamento" ter disparado no turno.
|
||
*
|
||
* Por quê: a skill "agendamento" (`lib/agent-engine/agent/skills.ts`) só injeta o
|
||
* corpo dela quando a ÚLTIMA mensagem inbound do turno bate uma keyword. Medido
|
||
* neste repo: o turno em que o lead ACEITA um horário oferecido ("pode ser amanhã
|
||
* às 9 então") raramente repete uma keyword de agendar — quem carrega a keyword é o
|
||
* turno ANTERIOR, que já passou. Sem o corpo da skill presente NAQUELE turno
|
||
* específico, o modelo confirmava o compromisso pela conversa, sem nunca chamar
|
||
* `crm_book_appointment` — sentença dita ao cliente, nada gravado no banco. Esta
|
||
* regra é curta, redundante com a skill de propósito e, por só depender de
|
||
* `agentConfig.toolIds` (não da mensagem do turno), fica sempre presente.
|
||
*
|
||
* ⚠️ Segundo parágrafo (2026-08-29): a mesma lacuna de keyword tem um irmão mais
|
||
* barato de cometer. Medido em produção: o lead disse "Pode ser segunda de manha"
|
||
* e depois só "?" — nenhuma das duas bate keyword da skill "agendamento", então o
|
||
* corpo dela (que tem a instrução "chame crm_find_free_slots e leia a resposta")
|
||
* nunca entrou no contexto. O primeiro parágrafo deste bloco só proíbe MENTIR
|
||
* ("confirmado" sem checar) — não obriga a CHECAR. Sem essa obrigação, o modelo
|
||
* tinha uma saída segura e preguiçosa: responder "vou verificar e te aviso" pra
|
||
* sempre, sem nunca chamar a ferramenta. O segundo parágrafo fecha essa saída.
|
||
*
|
||
* ⚠️ Terceiro parágrafo (2026-08-29, mesmo dia): o segundo parágrafo sozinho NÃO
|
||
* bastou — medido no mesmo teste, depois de publicado. Causa raiz achada no
|
||
* `system_prompt` que o PRÓPRIO tenant escreveu para este agente: ele instrui a
|
||
* "encaminhar dúvidas ou situações fora da sua autonomia ao gerente Fulano".
|
||
* O modelo estava classificando "confirmar horário" como uma dessas situações e
|
||
* respondendo "vou confirmar com o Fulano/a equipe" — coerente com a
|
||
* identidade que o tenant deu a ele, só que sem nunca chamar a ferramenta. Um
|
||
* agravante: a MESMA conversa já tinha várias respostas assim ANTES deste fix
|
||
* existir, e o modelo lê o próprio histórico — puxando a resposta pra manter
|
||
* consistência com o que ele mesmo já disse. O terceiro parágrafo nomeia o
|
||
* conflito explicitamente e resolve a favor da ferramenta: checar/marcar
|
||
* agenda com uma tool disponível NUNCA é "fora da autonomia", nem quando o
|
||
* prompt do tenant nomeia um gerente para outras decisões — e ele AINDA vale
|
||
* pra essas outras decisões (aprovar desconto, exceção de política etc.),
|
||
* porque este parágrafo só fala de checar/marcar horário.
|
||
*/
|
||
function agendaSystemBlock(toolIds: readonly string[]): string {
|
||
// ⚠️ Os nomes de ferramenta deste bloco saem TODOS da lista do PRÓPRIO agente —
|
||
// nenhum vem escrito à mão. Desde a #831 as combinações são muitas (quem tem só a
|
||
// conjunta, quem tem só a avulsa, quem tem as duas, com ou sem a consulta e a
|
||
// remarcação), e um dono aparando capacidades para caber no teto de 25 produz
|
||
// qualquer uma delas. Nomear ferramenta ausente é o modo de falha que o bloco
|
||
// irmão (`AGENDA_CONSULTA_SYSTEM_BLOCK`) existe para evitar: o modelo tenta
|
||
// chamá-la. Uma versão anterior nomeava "`crm_book_appointment` ou
|
||
// `crm_find_and_book_appointment`, a que estiver na sua lista" — e isso ainda
|
||
// ensina o nome de uma ferramenta que o agente não tem.
|
||
const tem = (nome: string): boolean => toolIds.includes(nome);
|
||
const marcar = nomesDasFerramentas(
|
||
['crm_book_appointment', 'crm_find_and_book_appointment'].filter(tem),
|
||
);
|
||
const remarcacao = tem('crm_reschedule_appointment')
|
||
? ' (ou `crm_reschedule_appointment`, para remarcação)'
|
||
: '';
|
||
|
||
return (
|
||
'## Agenda — nunca confirme sem checar\n' +
|
||
'Você só pode dizer a um lead que um horário/consulta/visita está confirmado DEPOIS de chamar ' +
|
||
`${marcar}${remarcacao} e ver o retorno confirmando o ` +
|
||
'sucesso. Isso vale mesmo quando o lead já aceitou um horário que você ofereceu — aceite verbal não é ' +
|
||
'reserva. NUNCA diga "confirmado", "está marcado" ou equivalente baseado só no histórico da conversa. ' +
|
||
// ⚠️ A ressalva é obrigatória: sem ela este parágrafo ENSINA o erro. Num tipo
|
||
// que exige aprovação, marcar devolve `aguarda_confirmacao: true` e o
|
||
// compromisso nasce `pending` — dizer "confirmado" ali é afirmar o que
|
||
// ninguém aprovou, e o cliente aparece num horário que pode ser recusado.
|
||
'⚠️ EXCEÇÃO: se o retorno trouxer `aguarda_confirmacao: true`, o horário foi apenas RESERVADO e ' +
|
||
'ainda depende de alguém da equipe aprovar. Nesse caso NÃO diga que está confirmado: diga que ' +
|
||
'separou o horário e que a equipe confirma. ' +
|
||
'Se ainda não chamou a ferramenta neste turno, chame antes de responder; se a chamada falhar ou você não ' +
|
||
'tiver certeza do resultado, diga que vai verificar e NÃO afirme que está confirmado.\n' +
|
||
// Sem a ferramenta que só CONSULTA, este parágrafo não tem o que mandar
|
||
// chamar: mandar chamar uma que MARCA seria mandar reservar um horário que o
|
||
// lead só mencionou. Quem tem a conjunta recebe o parágrafo dela, abaixo, e o
|
||
// gate de agenda continua armado para os dois.
|
||
(tem('crm_find_free_slots')
|
||
? 'Isso NÃO é desculpa para procrastinar: se o lead mencionou (agora ou em qualquer mensagem anterior da ' +
|
||
'conversa) um dia/horário específico que ainda não foi checado, chame `crm_find_free_slots` ' +
|
||
'NESTE turno antes de responder — não repita "vou verificar/confirmar e te aviso" sem ter chamado a ' +
|
||
'ferramenta. Um "vou verificar" só é aceitável na MESMA resposta em que você já chamou a ferramenta e ' +
|
||
'ela falhou ou não trouxe resultado; nunca como substituto de chamar.\n'
|
||
: '') +
|
||
// Preservar o `inicio` é o contrato de `crm_book_appointment` (`starts_at`). A
|
||
// conjunta recebe dia e hora, não o instante — o parágrafo não se aplica a ela.
|
||
(tem('crm_find_free_slots') && tem('crm_book_appointment')
|
||
? 'Se o lead escolheu um horário que VOCÊ já ofereceu nesta conversa com `crm_find_free_slots`, ele já ' +
|
||
'foi checado: preserve o `inicio` que a ferramenta devolveu e chame `crm_book_appointment` ' +
|
||
'diretamente. ' +
|
||
'NÃO consulte de novo montando datas/horas em UTC; só consulte outra vez se a reserva recusar o horário.\n'
|
||
: '') +
|
||
// Issue #831: consultar e encerrar o turno é o meio-caminho que deixa o lead sem
|
||
// agendamento. Quando a ferramenta conjunta existe, ela é o caminho PREFERIDO —
|
||
// confirmar o horário e gravar deixa de ser decisão de duas etapas do modelo.
|
||
(tem('crm_find_and_book_appointment')
|
||
? 'Se o lead já disse DIA e HORA, use `crm_find_and_book_appointment`: ela ' +
|
||
'confere a disponibilidade e grava o compromisso na MESMA chamada. Ela é o caminho preferido nesse caso ' +
|
||
'— não consulte e pare por aí, deixando o lead sem horário marcado. Se o horário ' +
|
||
'pedido não estiver livre, ela devolve os horários do dia; ofereça um deles ao lead.\n'
|
||
: '') +
|
||
'Checar e marcar horário com as ferramentas de agenda está SEMPRE dentro da sua ' +
|
||
'autonomia quando essas ferramentas estão disponíveis para você — mesmo que as instruções da empresa ' +
|
||
'peçam para encaminhar decisões fora da sua autonomia a um gerente/responsável nomeado (ex.: "fale com o ' +
|
||
'Fulano"). Isso vale para OUTRAS decisões (desconto, exceção de política, algo que a ferramenta não ' +
|
||
'cobre) — nunca para simplesmente consultar ou marcar um horário que a ferramenta resolve sozinha. NÃO ' +
|
||
'diga "vou confirmar/verificar com [nome de pessoa/equipe]" para justificar não ter chamado a ferramenta: ' +
|
||
'chame primeiro, e só fale de encaminhar a alguém se a ferramenta genuinamente não resolver.'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* O mesmo ensino para quem CONSULTA a agenda e não marca.
|
||
*
|
||
* ⚠️ Este bloco existe porque o de cima nomeia `crm_book_appointment` em toda
|
||
* frase, e há um arranjo legítimo e comum em que essa ferramenta não é dada ao
|
||
* agente de propósito: o negócio quer que uma PESSOA confirme cada horário, e a
|
||
* IA só consulta e registra o pedido. Clínica, salão, consultório.
|
||
*
|
||
* Antes desta divisão, esse agente não recebia bloco nenhum — a condição era
|
||
* `toolIds.includes('crm_book_appointment')` — e ficava sem justamente a parte
|
||
* que lhe cabe: não prometer "vou verificar e te aviso" sem ter consultado. Dar
|
||
* a ele o bloco inteiro seria pior: ensinaria uma ferramenta que ele não tem, e
|
||
* o modelo tentaria chamá-la.
|
||
*
|
||
* O que muda de conteúdo é só o desfecho: lá a checagem termina em marcar, aqui
|
||
* termina em oferecer o horário e dizer, sem rodeio, que quem confirma é uma
|
||
* pessoa. Isso não é hesitação — é o desenho do negócio, e o texto diz isso para
|
||
* o modelo não confundir com incerteza dele.
|
||
*/
|
||
const AGENDA_CONSULTA_SYSTEM_BLOCK =
|
||
'## Agenda — consulte antes de falar de horário\n' +
|
||
'Se o lead mencionou (agora ou em qualquer mensagem anterior da conversa) um dia/horário ' +
|
||
'específico que ainda não foi checado, chame crm_find_free_slots NESTE turno antes de responder. ' +
|
||
'Não repita "vou verificar e te aviso" sem ter chamado a ferramenta — um "vou verificar" só é ' +
|
||
'aceitável na MESMA resposta em que você já chamou e ela falhou ou não trouxe resultado.\n' +
|
||
'Você NÃO tem ferramenta para marcar: quem confirma o horário é uma pessoa da equipe. Então ' +
|
||
'NUNCA diga "confirmado", "está marcado", "reservei" ou equivalente — nem depois de o lead ' +
|
||
'aceitar um horário que você ofereceu. Diga que vai passar para a equipe confirmar. Isso é como ' +
|
||
'o negócio funciona, não uma limitação a esconder nem uma incerteza sua.\n' +
|
||
'Consultar a agenda com crm_find_free_slots está SEMPRE dentro da sua autonomia — mesmo que as ' +
|
||
'instruções da empresa peçam para encaminhar decisões a um responsável nomeado. Aquilo vale para ' +
|
||
'OUTRAS decisões (desconto, exceção de política); nunca para simplesmente olhar quais horários ' +
|
||
'existem. Não use "vou confirmar com [nome]" como desculpa para não ter consultado: consulte ' +
|
||
'primeiro, e aí diga a quem passa.';
|
||
|
||
/**
|
||
* O PRIMEIRO PASSO da cadeia de agenda, residente (#1019).
|
||
*
|
||
* ─── O que faltava, medido ──────────────────────────────────────────────────
|
||
*
|
||
* Os dois blocos acima nomeiam `crm_find_free_slots` em toda frase e
|
||
* `crm_list_event_types` em NENHUMA. A cadeia de dois passos — listar os tipos,
|
||
* pegar o `slug`, consultar os horários COM esse slug — existia só na
|
||
* `description` da própria ferramenta, que é onde o modelo a lê por último e
|
||
* sem o peso de uma instrução. Um agente com as três capacidades ligadas
|
||
* chamava a lista e parava ali; o relato da issue mede 4 chamadas de lista com
|
||
* o slug disponível e zero de `crm_find_free_slots` na sequência.
|
||
*
|
||
* ─── Por que este bloco é CONDICIONAL, e não texto fixo ─────────────────────
|
||
*
|
||
* Nomear `crm_list_event_types` para quem não a tem seria exatamente o erro que
|
||
* a divisão dos outros dois blocos já evita (`AGENDA_CONSULTA_SYSTEM_BLOCK`:
|
||
* "dar a ele o bloco inteiro seria pior — ensinaria uma ferramenta que ele não
|
||
* tem, e o modelo tentaria chamá-la"). Por isso o bloco entra só quando o
|
||
* agente tem as DUAS pontas: a lista e quem consome o slug.
|
||
*
|
||
* Ensino, não garantia: a garantia determinística é o `agendaStallGate`
|
||
* (`before-send.ts`), que agora reconhece a promessa feita com o nome do
|
||
* serviço. Os dois juntos é que fecham o caso — um ensina o caminho, o outro
|
||
* impede que a resposta saia por fora dele.
|
||
*/
|
||
const AGENDA_CADEIA_SYSTEM_BLOCK =
|
||
'## Agenda — os dois passos, no mesmo turno\n' +
|
||
'Para falar de um horário REAL você precisa de duas coisas: o TIPO de atendimento (o `slug`) e os ' +
|
||
'horários daquele tipo. Você tem `crm_list_event_types` para a primeira e `crm_find_free_slots` para ' +
|
||
'a segunda — e o segundo passo PRECISA do `slug` que o primeiro devolve.\n' +
|
||
'Se o lead pediu horário e você ainda não tem o `slug` do tipo (ou não sabe a qual tipo ele se ' +
|
||
'refere), chame `crm_list_event_types` NESTE turno, escolha o tipo pelo que o lead descreveu e chame ' +
|
||
'`crm_find_free_slots` com esse `slug` NO MESMO TURNO, antes de responder. Parar depois da lista e ' +
|
||
'responder "vou verificar/organizar" é o defeito: a lista é o começo da conversa com a agenda, não a ' +
|
||
'resposta. Se o tipo que o lead pediu não estiver na lista, diga isso a ele nomeando o que existe — ' +
|
||
'não prometa verificar o que você já sabe que não tem.\n' +
|
||
'Nunca invente um `slug`: ele vem da lista, escrito igualzinho.';
|
||
|
||
/**
|
||
* Os blocos de agenda que ESTE agente recebe — a decisão num lugar só, testável.
|
||
*
|
||
* A régua é o que o agente TEM: os dois blocos de ensino nomeiam ferramentas, e
|
||
* nomear uma ferramenta ausente faz o modelo tentar chamá-la.
|
||
*/
|
||
export function blocosDeAgendaResidentes(toolIds: readonly string[]): string[] {
|
||
const blocos: string[] = [];
|
||
// A regua de "quem marca" e a da main (#831): `temFerramentaDeMarcacao` conta
|
||
// tambem `crm_find_and_book_appointment`, e o texto do bloco nomeia so as
|
||
// ferramentas que ESTE agente tem — usar o texto fixo aqui desfaria a #831 no
|
||
// caminho do turno.
|
||
if (temFerramentaDeMarcacao(toolIds)) {
|
||
blocos.push(agendaSystemBlock(toolIds));
|
||
} else if (toolIds.includes('crm_find_free_slots')) {
|
||
// Só consulta: o bloco de cima nomeia uma ferramenta que ele não tem.
|
||
blocos.push(AGENDA_CONSULTA_SYSTEM_BLOCK);
|
||
}
|
||
if (
|
||
toolIds.includes('crm_list_event_types') &&
|
||
toolIds.includes('crm_find_free_slots')
|
||
) {
|
||
blocos.push(AGENDA_CADEIA_SYSTEM_BLOCK);
|
||
}
|
||
return blocos;
|
||
}
|
||
|
||
/**
|
||
* Tools de agenda cuja EXECUÇÃO neste turno arma o `agendaStallGate` (before-send.ts) —
|
||
* ver o wrap no loop de montagem das tools MCP, mais abaixo.
|
||
*/
|
||
const AGENDA_TOOL_NAMES = new Set([
|
||
'crm_find_free_slots',
|
||
'crm_book_appointment',
|
||
'crm_reschedule_appointment',
|
||
// Issue #831: a ferramenta que consulta E marca numa chamada só. Ela EXECUTA
|
||
// marcação, então precisa armar o mesmo gate: sem isto, o turno em que a IA
|
||
// marcou passaria sem o `agendaStallGate` — o gate que existe justamente para
|
||
// detectar "falou de agenda e nada foi gravado".
|
||
'crm_find_and_book_appointment',
|
||
]);
|
||
|
||
/**
|
||
* O agente consegue GRAVAR um horário sozinho (marcar ou remarcar)?
|
||
*
|
||
* Duas ferramentas MARCAM um horário novo: `crm_book_appointment` e, desde a issue
|
||
* #831, a que consulta e marca numa chamada só (`crm_find_and_book_appointment`).
|
||
* Ela decide QUAL bloco residente o agente recebe (`blocoResidenteDaAgenda`): o de
|
||
* quem marca ou o de quem só consulta — divergindo, o bloco diria "você NÃO tem
|
||
* ferramenta para marcar" a um agente que tem.
|
||
*
|
||
* O veto do gate NÃO lê esta função, e já leu: ele recebia um booleano
|
||
* `podeMarcar` e escrevia uma lista fixa de ferramentas para todo agente que
|
||
* marca — inclusive as que o agente não tem. Hoje ele recebe a lista exata
|
||
* (`ferramentasDeAgendaDoAgente`).
|
||
*
|
||
* ⚠️ `crm_reschedule_appointment` está FORA, de propósito. Ela grava na agenda,
|
||
* mas só MOVE um compromisso que já existe — não cria um. Incluí-la alargava o
|
||
* portão além do que a #831 pede: o agente que tem só a remarcação (e que antes
|
||
* caía no bloco de só-consulta) passava a receber o `agendaSystemBlock`, que
|
||
* nomeia ferramentas de marcar que ele não tem — exatamente o modo de falha que
|
||
* o bloco irmão existe para evitar, e que um dono aparando capacidades para caber
|
||
* no teto de 25 tende a produzir.
|
||
*/
|
||
export function temFerramentaDeMarcacao(toolIds: readonly string[]): boolean {
|
||
return (
|
||
toolIds.includes('crm_book_appointment') ||
|
||
toolIds.includes('crm_find_and_book_appointment')
|
||
);
|
||
}
|
||
|
||
/**
|
||
* O agente tem alguma ferramenta de agenda? É o que ARMA o `agendaStallGate`.
|
||
*
|
||
* Exportada porque o caminho de prévia (`preview.ts`) monta o mesmo contexto de
|
||
* gate por conta própria, e as duas condições precisam ser a MESMA: se a prévia
|
||
* armar diferente do turno real, quem afina o prompt testa contra um gate que não
|
||
* é o que vai rodar — e o defeito aparece só com cliente na frente.
|
||
*/
|
||
export function temFerramentaDeAgenda(toolIds: readonly string[]): boolean {
|
||
return toolIds.some((t) => AGENDA_TOOL_NAMES.has(t));
|
||
}
|
||
|
||
/**
|
||
* As ferramentas de agenda que ESTE agente tem — a lista que o veto do
|
||
* `agendaStallGate` nomeia.
|
||
*
|
||
* ⚠️ É a lista, e não um booleano, porque o texto do veto é ENSINO: ele diz ao
|
||
* modelo o que chamar. Com `podeMarcar: boolean` o gate só sabia que o agente
|
||
* marca, e nomeava a família inteira — `crm_book_appointment` para quem tem só a
|
||
* conjunta, `crm_reschedule_appointment` para quem não remarca. Nomear ferramenta
|
||
* ausente faz o modelo tentar chamá-la, e a correção vira um segundo defeito.
|
||
*/
|
||
export function ferramentasDeAgendaDoAgente(toolIds: readonly string[]): string[] {
|
||
return [...AGENDA_TOOL_NAMES].filter((t) => toolIds.includes(t));
|
||
}
|
||
|
||
/**
|
||
* QUAL bloco residente de Agenda este agente recebe — ou nenhum.
|
||
*
|
||
* A escolha vivia inline dentro de `executarTurnoDoAgente`, inalcançável sem o
|
||
* runtime inteiro: nenhum teste chegava nela, e o portão que a #831 alargou
|
||
* (`temFerramentaDeMarcacao`) só era exercitado pela própria função, nunca pelo
|
||
* ponto de uso. Aqui ela é chamável — e o que se prende é o par
|
||
* "quem recebe o bloco de marcar" × "quem recebe o de só consultar", que é
|
||
* exatamente onde o texto ensina, ou não, uma ferramenta que o agente não tem.
|
||
*/
|
||
export function blocoResidenteDaAgenda(toolIds: readonly string[]): string | null {
|
||
// Uma lei só: quem decide os blocos residentes da Agenda e
|
||
// `blocosDeAgendaResidentes` — esta fatia (#1019) acrescentou a CADEIA de dois
|
||
// passos como segundo bloco. Aqui fica o PRIMEIRO deles (o de marcar ou o de so
|
||
// consultar), que e o par que o teste da #831 prende.
|
||
return blocosDeAgendaResidentes(toolIds)[0] ?? null;
|
||
}
|
||
|
||
export interface InboundTurnKnobs {
|
||
/** últimas N mensagens no contexto de abertura (LEAD_CONTEXT_HISTORY_LIMIT) */
|
||
historyLimit: number;
|
||
/** teto do payload do contexto (LEAD_CONTEXT_MAX_TOKENS) */
|
||
maxContextTokens: number;
|
||
/** orçamento fixo do índice de notas do lead injetado no sufixo (LEAD_NOTES_INDEX_MAX_TOKENS) */
|
||
notesIndexMaxTokens: number;
|
||
/** teto de steps do loop de tools por run (AGENT_MAX_STEPS) — circuit breaker fino é F2-15 */
|
||
maxSteps: number;
|
||
/**
|
||
* Teto de mensagens FÍSICAS enviadas ao lead neste turno (MAX_SENDS_PER_TURN),
|
||
* send_message + send_template somados, bolhas incluídas. Ausente = usa
|
||
* `DEFAULT_MAX_SENDS_PER_TURN` — main.ts sempre o preenche pelo knob do env;
|
||
* testes que não exercitam o teto o omitem sem custo.
|
||
*/
|
||
maxSendsPerTurn?: number;
|
||
/** atraso do reagendamento em veto/queued herdado da F2-06 (SEND_QUEUED_RETRY_MS) */
|
||
queuedRetryDelayMs: number;
|
||
/**
|
||
* Teto da régua de resposta obsoleta (RESPOSTA_OBSOLETA_TETO_MS) — ver
|
||
* `respostaFicouObsoleta`. Ausente = desligada (testes que não a exercitam).
|
||
*/
|
||
respostaObsoletaTetoMs?: number;
|
||
/** circuit breaker de tools por run (F2-15) — env TOOL_BREAKER_* */
|
||
breaker: ToolBreakerThresholds;
|
||
/**
|
||
* Janela aceitável do follow-up agendado pela tool schedule_followup (F3-02).
|
||
* Ausente = a tool NÃO é oferecida ao modelo neste run (main.ts sempre a preenche
|
||
* pelos knobs do env; testes que não exercitam a tool a omitem sem custo).
|
||
*/
|
||
followup?: FollowupWindowKnobs;
|
||
/**
|
||
* Compaction + flush pré-compaction (F3-07). Ausente = desligada (o turno usa o
|
||
* transcript cru, capado por get_lead_context) — main.ts sempre a preenche pelos
|
||
* knobs do env; testes que não a exercitam a omitem sem custo.
|
||
*/
|
||
compaction?: CompactionKnobs;
|
||
/**
|
||
* Pruning de tool results antigos (F3-10). Ausente = desligado (as responseMessages do
|
||
* run seguem íntegras na chamada de fechamento) — main.ts sempre o preenche pelos knobs
|
||
* do env; testes que não o exercitam o omitem sem custo.
|
||
*/
|
||
prune?: PruneToolResultsKnobs;
|
||
/**
|
||
* Skills situacionais (F3-09): gravação dos near-misses de matching como candidato ao
|
||
* golden set. Ausente/false = misses NÃO gravados (o matching + injeção de corpo seguem
|
||
* valendo) — main.ts sempre o preenche pelo env (`GOLDEN_CANDIDATES_ENABLED`); testes
|
||
* que não querem linha no banco omitem a knob. Desde a #1695 a gravação é uma LINHA em
|
||
* `golden_candidates` (só rótulo, sem texto de cliente), nunca arquivo em disco.
|
||
*/
|
||
goldenCandidates?: boolean;
|
||
/**
|
||
* Stage-classifier por turno (F3-11; SalesGPT). Ausente = classificador NÃO roda (o
|
||
* turno segue sem hint de estágio) — main.ts sempre o preenche pelo env; testes que não
|
||
* o exercitam o omitem sem custo. A DIVERGÊNCIA classificador×modelo vira candidato em
|
||
* `golden_candidates` (mesma knob da F3-09) — só se a knob estiver ligada.
|
||
*/
|
||
stageClassifier?: StageClassifierKnobs;
|
||
/**
|
||
* Classifier anti-jailbreak no inbound do lead (F4-04; advisório). Ausente = NÃO roda (o
|
||
* turno segue sem flag) — main.ts sempre o preenche pelo env; testes que não o exercitam o
|
||
* omitem sem custo. Flag ALTA + tentativa de promessa fora de tabela (F4-01) no MESMO turno
|
||
* escala para inbox_items (dedup por episódio).
|
||
*/
|
||
jailbreak?: JailbreakClassifierKnobs;
|
||
/**
|
||
* Modo do gate de disclosure (F4-05; DISCLOSURE_MODE): 'inject' (default — o disclosure é
|
||
* sempre adicionado à 1ª mensagem) ou 'veto' (bloqueia + ensina). Ausente = default 'inject'
|
||
* do runBeforeSend. main.ts sempre o preenche pelo env.
|
||
*/
|
||
disclosureMode?: DisclosureMode;
|
||
/**
|
||
* Camada SEMÂNTICA de promessa (F4-02) na cadeia before_send (gate 5 da ordem final F4-08).
|
||
* Ausente = camada NÃO roda (o gate fica no-op) — testes que não a exercitam a omitem; main.ts
|
||
* a preenche pelo env (PROMISE_SEMANTIC_*). `enabled=false` também mantém o gate no-op.
|
||
* CUSTO: com enabled, é UMA chamada de modelo auxiliar POR TENTATIVA DE ENVIO (não por turno).
|
||
*/
|
||
promiseSemantic?: { enabled: boolean; model?: string };
|
||
/**
|
||
* Onda 5 (Task 5.1) — modelo auxiliar dos turnos `classify`/`decide_timing` do
|
||
* sistema de fluxos de follow-up (lib/agent-engine/agent/followup-flow-classify.ts).
|
||
* Ausente = usa o defaultModel da org (mesma convenção de stageClassifier/jailbreak).
|
||
*/
|
||
followupAi?: { model?: string };
|
||
/**
|
||
* Janela de validade da autorização de IA de um contato (gate opt-in
|
||
* `channel_sessions.metadata.ai_gate = 'allowlist'`). Só consultada quando o
|
||
* canal tem o gate ligado. Ausente nos testes que não o exercitam — o default
|
||
* de 21 dias é aplicado.
|
||
*/
|
||
allowlistTtlMs?: number;
|
||
}
|
||
|
||
/** Default de `allowlistTtlMs` (21 dias) para testes que omitem o knob. */
|
||
export const ALLOWLIST_TTL_MS_PADRAO = 21 * 24 * 60 * 60 * 1000;
|
||
|
||
export interface InboundTurnDeps {
|
||
crmCfg: CrmEdgeConfig;
|
||
llmCfg: LlmEdgeConfig;
|
||
knobs: InboundTurnKnobs;
|
||
log: Logger;
|
||
/** testes: registry com provider fake — produção usa o default do seam */
|
||
registry?: ProviderRegistry;
|
||
embed?: typeof import('@/lib/ai/embed').embedText;
|
||
/**
|
||
* Seam de canal (F2-25): fábrica do ChannelAdapter para o pool do job. Default =
|
||
* WAHA-via-CRM (o único adapter da v1). Trocar o adapter (ex.: Cloud API) NÃO
|
||
* muda este handler — prova em daemon/test/channel-adapter.test.ts.
|
||
*/
|
||
channel?: (pool: pg.Pool) => ChannelAdapter;
|
||
/**
|
||
* Relógio injetável (F2-13) — a janela horária do gate anti-ban é avaliada nele.
|
||
* Default `() => new Date()`; os testes fixam um instante dentro da janela para
|
||
* determinismo.
|
||
*/
|
||
clock?: () => Date;
|
||
/**
|
||
* Espera do throttle da cadeia before_send (F2-13) — injetável só para teste.
|
||
* Default = sleep real (runBeforeSend cai em `realSleep`). O E2E de fase (F2-18)
|
||
* passa um spy que registra o waitMs sem esperar de verdade: torna o espaçamento
|
||
* anti-ban observável no artefato de trace de forma determinística.
|
||
*/
|
||
sleep?: (ms: number) => Promise<void>;
|
||
/**
|
||
* O Jev no turno — na camada anti-manipulação e no roteador de intenção.
|
||
* Injetável só para teste (chave e `fetch` dublês). Default = a chave da
|
||
* organização e o egress com allowlist (`lib/ai/decisao/ponto.ts`).
|
||
*/
|
||
jev?: DependenciasDoPonto;
|
||
}
|
||
|
||
/** Checkpoint mais recente do lead — a memória que atravessa sessões. */
|
||
export async function latestCheckpoint(
|
||
db: Queryable,
|
||
tenantId: string,
|
||
leadId: string,
|
||
): Promise<LeadCheckpointRow | null> {
|
||
const boundary = currentExecutionBoundary();
|
||
const { rows } = await db.query<LeadCheckpointRow>(
|
||
`select * from lead_checkpoints where organization_id=$1 and contact_id=$2
|
||
${boundary ? 'and conversation_id=$3 and service_revision=$4 and demanda_id is not distinct from $5::uuid and demanda_revision is not distinct from $6::bigint' : ''}
|
||
order by seq desc limit 1`,
|
||
boundary
|
||
? [
|
||
tenantId,
|
||
leadId,
|
||
boundary.conversation_id,
|
||
boundary.service_revision,
|
||
boundary.demanda_id,
|
||
boundary.demanda_revision,
|
||
]
|
||
: [tenantId, leadId],
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
/**
|
||
* O checkpoint DO TURNO indicado — não "o mais recente do lead".
|
||
*
|
||
* `latestCheckpoint` é a pergunta certa para ABRIR um turno e a errada para
|
||
* PROCESSAR um: entre o fim do turno N e o claim do job do Operador N cabe o turno
|
||
* N+1 inteiro. A fila ordena por `(priority, run_after)`, o job do Operador nasce
|
||
* com `run_after = now()` e o inbound com `now() + INBOUND_DEBOUNCE_MS` (8s) —
|
||
* então uma mensagem que chega enquanto o turno corrente fecha é servida ANTES, e o
|
||
* Operador N acordaria lendo a declaração N+1. O efeito é a mesma promessa
|
||
* executada duas vezes e um aviso aberto duas vezes para uma promessa só.
|
||
*
|
||
* A chave sempre viajou no payload (`origin_job_id`) e era usada só como campo de
|
||
* log. Sem índice novo: `idx_lead_checkpoints_latest (organization_id, contact_id,
|
||
* seq desc)` já restringe a varredura àquele lead, e o `job_id` filtra em cima.
|
||
*/
|
||
export async function checkpointDoJob(
|
||
db: Queryable,
|
||
tenantId: string,
|
||
leadId: string,
|
||
jobId: string,
|
||
): Promise<LeadCheckpointRow | null> {
|
||
const { rows } = await db.query<LeadCheckpointRow>(
|
||
`select * from lead_checkpoints
|
||
where organization_id = $1 and contact_id = $2 and job_id = $3
|
||
order by seq desc
|
||
limit 1`,
|
||
[tenantId, leadId, jobId],
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
/**
|
||
* A decisão de ENFILEIRAR o Operador — irmã de `decidirSeRoda`, que decide se ele
|
||
* RODA.
|
||
*
|
||
* A segunda era função pura desde o primeiro dia; a primeira ficou implícita em
|
||
* "sempre". A decisão (e) da spec declarou o custo em dinheiro (+1 chamada com o
|
||
* papel ligado) e ninguém declarou o custo em CAPACIDADE DE FILA, pago mesmo com o
|
||
* papel desligado — o default de toda instalação.
|
||
*
|
||
* Por turno, para escrever uma linha de log num contêiner que o dono do negócio
|
||
* nunca abre: um job, um slot de `QUEUE_MAX_CONCURRENCY` e a única vaga daquele
|
||
* lead no lote do claim (`distinct on (coalesce(contact_id, id))`). Sob rajada, é o
|
||
* turno de CRM sendo servido antes da próxima mensagem do cliente.
|
||
*
|
||
* O que se perde: o registro "papel_desligado" do handler. Aceitável — era um
|
||
* `log.info` de worker, que este arquivo classifica como não-superfície, e a linha
|
||
* do chamador o repõe no mesmo nível com custo zero de fila.
|
||
*
|
||
* O que isto NÃO conserta, e um leitor futuro vai supor que sim: com o papel
|
||
* desligado, promessa feita pelo Conversador continua sem registro na Central. Era
|
||
* assim antes e continua sendo.
|
||
*/
|
||
export function decidirSeEnfileiraOperador(input: {
|
||
temAgentePublicado: boolean;
|
||
papelLigado: boolean;
|
||
}): { enfileira: boolean; porque: 'ligado' | 'sem_agente' | 'papel_desligado' } {
|
||
if (!input.temAgentePublicado) return { enfileira: false, porque: 'sem_agente' };
|
||
if (!input.papelLigado) return { enfileira: false, porque: 'papel_desligado' };
|
||
return { enfileira: true, porque: 'ligado' };
|
||
}
|
||
|
||
/**
|
||
* A mensagem que acaba de chegar é uma fonte factual: se ela tem texto, o
|
||
* agente não pode dizer ao cliente que ela veio vazia. Prompt reduz esse erro,
|
||
* mas não é uma barreira de envio — o modelo ainda pode repetir um resumo
|
||
* antigo contaminado. Esta detecção fica no único caminho que fala no canal.
|
||
*/
|
||
export function claimsCurrentInboundIsEmpty(candidate: string, currentInbound: string): boolean {
|
||
if (currentInbound.trim() === '') return false;
|
||
|
||
const emptyClaim = '(?:em\\s+branco|vazi[ao]|sem\\s+texto)';
|
||
// ⚠️ `ela` NÃO entra aqui, e a razão está medida. Como pronome, ela casa com
|
||
// qualquer sujeito feminino da frase — e "vazio" é palavra corrente numa
|
||
// agenda. Num corpus de 6 frases legítimas de atendimento, a alternativa
|
||
// vetava 1: "Consegui uma vaga com a Drª Mara — ela ficou com a tarde vazia na
|
||
// quinta." O preço de tirá-la é não pegar a frase falsa escrita SÓ com
|
||
// pronome ("ela veio vazia"); o preço de mantê-la era barrar atendimento
|
||
// legítimo, e esse é o lado que cala o cliente. As seis frases estão no teste,
|
||
// nomeadas — quem quiser alargar de novo alarga contra elas.
|
||
const messageReference = '(?:mensagem|texto|recado|última\\s+mensagem)';
|
||
return new RegExp(
|
||
`\\b${messageReference}\\b[\\s\\S]{0,90}\\b${emptyClaim}\\b|\\b${emptyClaim}\\b[\\s\\S]{0,90}\\b${messageReference}\\b`,
|
||
'i',
|
||
).test(candidate);
|
||
}
|
||
|
||
/**
|
||
* Tudo que o cliente escreveu desde a última vez que ALGUÉM do nosso lado
|
||
* respondeu — cada mensagem inteira, em ordem, nunca emendadas.
|
||
*
|
||
* ## Por que não basta "a última inbound"
|
||
*
|
||
* O drain COALESCE rajada: com `INBOUND_DEBOUNCE_MS` (default 8000), a segunda
|
||
* mensagem do cliente não ganha job próprio — ela "entra de carona" no job da
|
||
* primeira (`edge/crm/drain.ts`, "Coalescência"). O turno responde à mensagem que
|
||
* o job aponta, e isso está certo; mas quem só olhasse essa mensagem não OUVIRIA
|
||
* a segunda. Um cliente que escreve "oi" e, três segundos depois, "quero falar
|
||
* com uma pessoa" tem que ser ouvido no segundo: calar um pedido de humano é
|
||
* pior que o defeito que o pin do job veio consertar.
|
||
*
|
||
* ## Por que uma LISTA, e não um texto emendado
|
||
*
|
||
* `ehPalavraIsolada` (lib/opt-out/deteccao.ts) exige que a mensagem INTEIRA seja
|
||
* a palavra-chave — é assim que "PARAR" descadastra e "tem como parar a dor?"
|
||
* não. Emendar as mensagens da rajada num texto só destruiria exatamente essa
|
||
* propriedade: "oi\nPARAR" não é palavra isolada, e o opt-out deixaria de
|
||
* disparar. Quem consome isto roda o detector POR MENSAGEM.
|
||
*
|
||
* O corte é a última OUTBOUND (resposta de humano conta — ela também é do nosso
|
||
* lado). Sem nenhuma outbound na janela, tudo que o cliente disse segue sem
|
||
* resposta, e é isso que a lista devolve.
|
||
*/
|
||
export function inboundsNaoRespondidos(messages: readonly LeadContextMessage[]): string[] {
|
||
const pendentes: string[] = [];
|
||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||
const m = messages[i];
|
||
if (m === undefined) continue;
|
||
if (m.direction === 'outbound') break;
|
||
if (m.body.trim() !== '') pendentes.unshift(m.body);
|
||
}
|
||
return pendentes;
|
||
}
|
||
|
||
/**
|
||
* Parâmetros do run que DIFEREM entre inbound (F2-09) e follow-up (F3-03): os ids
|
||
* de envio (de fonte confiável — payload do drain no inbound, row do lead no
|
||
* follow-up, nunca do payload do modelo) e a montagem da mensagem de abertura,
|
||
* chamada DEPOIS do ritual de leitura (o follow-up injeta o bloco temporal aqui,
|
||
* no SUFIXO — depois do prefixo cacheável, sem invalidar o cache F2-17).
|
||
*/
|
||
export interface AgentTurnInput {
|
||
resolvedAgent?: TurnAgentResolution;
|
||
/** número (channel_sessions.id do CRM) — chave da serialização anti-ban do envio. */
|
||
channelSessionId: string;
|
||
/** conversa do CRM — destino do send_message. */
|
||
conversationId: string;
|
||
/** Id da mensagem que criou o job inbound; não é usado por follow-ups. */
|
||
inboundMessageId?: string;
|
||
/** monta a abertura APÓS o ritual de leitura (inbound vs. bloco temporal do follow-up). */
|
||
buildOpening: (ritual: {
|
||
previous: LeadCheckpointRow | null;
|
||
leadState: LeadStateRow | null;
|
||
context: LeadContext;
|
||
/** índice da memória do lead (F3-05), já dentro do orçamento; vai no sufixo. */
|
||
notesIndexBlock: string;
|
||
/**
|
||
* Compromissos já marcados deste contato (issue #512).
|
||
*
|
||
* OPCIONAL pela mesma razão que `projeta`, e ela é externa ao desenho:
|
||
* `tests/invariants/**` é congelado por hook de governança, e torná-lo
|
||
* obrigatório forçaria a editar um invariante existente só para satisfazer
|
||
* o compilador.
|
||
*/
|
||
compromissosBlock?: string;
|
||
/** Texto exato da mensagem que acordou este turno inbound. */
|
||
currentInboundText?: string;
|
||
/**
|
||
* Projetar o contexto (spec 16 §4)? Decidido pelo turno, ver `turnoProjeta`.
|
||
*
|
||
* OPCIONAL no tipo, e a razão é externa ao desenho: `tests/invariants/**` é
|
||
* congelado por hook de governança, e torná-lo obrigatório forçaria a editar
|
||
* um invariante existente só para satisfazer o compilador — o que a catraca
|
||
* proíbe, com razão. `runAgentTurn` SEMPRE o passa; o opcional só existe para
|
||
* quem constrói um ritual à mão (testes).
|
||
*
|
||
* O custo está registrado: um chamador novo que esqueça o campo não projeta,
|
||
* em silêncio. A direção do esquecimento é a segura (comportamento de hoje,
|
||
* com o gate de saída cobrindo), mas é esquecimento mesmo assim.
|
||
*/
|
||
projeta?: boolean;
|
||
/** ferramentas que saíram para o Operador — o prompt não pode citá-las. */
|
||
entregues?: readonly string[];
|
||
}) => string;
|
||
}
|
||
|
||
/**
|
||
* Núcleo do run do agente, compartilhado por inbound_turn (F2-09) e followup_turn
|
||
* (F3-03): ritual de abertura, loop de tools, fechamento com checkpoint e veto. Não
|
||
* guarda NADA entre invocações — sessão fresca por job (todo estado no closure). O
|
||
* que varia entre os dois tipos de turno vem em `input` (AgentTurnInput).
|
||
*/
|
||
/**
|
||
* O aviso de que o agente atendeu SEM as capacidades configuradas.
|
||
*
|
||
* Vai para a Central de avisos (`agent_inbox_items`) porque é lá que o dono do
|
||
* negócio olha — log de worker em VPS não é superfície de nada.
|
||
*
|
||
* Dedup por episódio ABERTO da organização (mesmo padrão do handoff): o defeito
|
||
* é sistêmico, não por conversa, e uma retentativa em rajada viraria dezenas de
|
||
* linhas idênticas — inbox inundado é inbox ignorado. Quem resolver o item e
|
||
* vir o problema voltar recebe um item novo, que é o comportamento certo.
|
||
*
|
||
* Best-effort de propósito: se ATÉ o aviso falhar, o turno continua. Derrubar o
|
||
* atendimento do cliente para reclamar de uma tool extra seria trocar um
|
||
* problema pequeno por um grande.
|
||
*/
|
||
export async function avisarCapacidadesAusentes(
|
||
db: pg.Pool,
|
||
tenantId: string,
|
||
conversationId: string,
|
||
detalhe: string,
|
||
log: Logger,
|
||
): Promise<void> {
|
||
try {
|
||
await db.query(
|
||
`insert into agent_inbox_items (organization_id, kind, severity, title, body, ref_kind, ref_id)
|
||
select $1, 'capabilities_missing', 'critical', $2, $3, 'conversation', $4
|
||
where not exists (
|
||
select 1 from agent_inbox_items
|
||
where organization_id = $1 and kind = 'capabilities_missing' and status = 'open'
|
||
)`,
|
||
[
|
||
tenantId,
|
||
'O agente atendeu sem as capacidades que você ligou',
|
||
'As ferramentas configuradas na tela do agente não puderam ser carregadas neste ' +
|
||
'atendimento, e ele respondeu ao cliente sem elas. A conversa não foi interrompida. ' +
|
||
`Motivo técnico: ${detalhe}`,
|
||
conversationId,
|
||
],
|
||
);
|
||
} catch (err) {
|
||
log.warn('aviso de capacidades ausentes não foi gravado', {
|
||
error: (err instanceof Error ? err.message : String(err)).slice(0, 120),
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* AS DETERMINÍSTICAS DO INBOUND, TAMBÉM ANTES DO RASCUNHO DO ASSISTIDO (#1648).
|
||
*
|
||
* O ramo assistido devolvia ANTES das detecções de STOP/opt-out e de pedido de
|
||
* humano. Na prática o contato escrevia "pare de me mandar mensagem" e o bot
|
||
* seguia elegível: sem silêncio durável (`force_human` +
|
||
* `bot_silenced_until='infinity'`), sem cancelamento dos follow-ups agendados e
|
||
* sem item de handoff na Central — só um rascunho na fila de aprovação, que
|
||
* alguém podia nunca aprovar. Risco de LGPD: o pedido de parar ficava
|
||
* dependendo de uma autorização humana que podia nunca vir, e quando ela vinha
|
||
* o contato já tinha recebido mais mensagens no meio-tempo.
|
||
*
|
||
* Estas detecções são regex sobre o que o cliente ainda NÃO teve resposta — não
|
||
* gastam token, não chamam modelo e não dependem do modo de operação. É por
|
||
* isso que elas podem (e devem) rodar aqui, antes do desvio para
|
||
* `generateReplyDraft`, com o MESMO mecanismo durável do caminho automático:
|
||
*
|
||
* 1. `avisarLeadDaEscalacao` PRIMEIRO — ordem obrigação, não estilo: o aviso
|
||
* tem de sair antes de `force_human` armar o gate que o vetaria;
|
||
* 2. `performHumanHandoff` — silencia para sempre, cancela os crons
|
||
* pendentes, grava a passagem e abre (ou adenda) o item na Central.
|
||
*
|
||
* Idempotente, como lá: um retry re-executa tudo sem duplicar efeito. Devolve
|
||
* `true` quando o turno foi silenciado — aí não há rascunho o que rascunhar e
|
||
* nada mais pode sair nele.
|
||
*/
|
||
export async function deteccoesDeterministicasDoAssistido(
|
||
pool: pg.Pool,
|
||
deps: InboundTurnDeps,
|
||
args: {
|
||
job: JobRow;
|
||
tenantId: string;
|
||
conversationId: string;
|
||
channelSessionId: string;
|
||
leadId: string;
|
||
agent: PublishedAgentConfig;
|
||
log: Logger;
|
||
/**
|
||
* Mensagem fixada no job — plano B quando a leitura do contexto do lead
|
||
* falha. Um pedido de parar não pode virar "sem detecção" só porque o CRM
|
||
* está fora do ar; a mensagem que acordou o turno é a mesma de sempre.
|
||
*/
|
||
inboundMessageId?: string;
|
||
},
|
||
): Promise<boolean> {
|
||
const { job, tenantId, conversationId, channelSessionId, leadId, agent, log } = args;
|
||
const clock = deps.clock ?? ((): Date => new Date());
|
||
|
||
// O QUE O CLIENTE DISSE e ainda não foi respondido — a MESMA fonte do caminho
|
||
// automático (`inboundsNaoRespondidos`): um pedido de parar que chegou na 2ª
|
||
// mensagem de uma rajada não pode ser calado por ler só a última linha.
|
||
let inboundsPendentes: string[] = [];
|
||
let optedOutThisTurn = false;
|
||
let lgpd: AvisoDeEscalacaoOpts['lgpd'];
|
||
let contextoLido = false;
|
||
try {
|
||
const abertura = await getLeadContext(
|
||
pool,
|
||
deps.crmCfg,
|
||
{
|
||
tenantId,
|
||
leadId,
|
||
conversationId,
|
||
fuso: await fusoDaOrganizacao(pool, tenantId, log),
|
||
},
|
||
{ historyLimit: agent.historyMessageWindow, maxTokens: agent.historyTokenWindow },
|
||
);
|
||
if (abertura.ok) {
|
||
contextoLido = true;
|
||
inboundsPendentes = inboundsNaoRespondidos(abertura.context.messages);
|
||
optedOutThisTurn = abertura.context.contact.is_blocked;
|
||
lgpd = abertura.lgpd;
|
||
} else {
|
||
log.warn('modo assistido: contexto do lead não lido — detecção cai na mensagem fixada', {
|
||
code: abertura.error.code,
|
||
});
|
||
}
|
||
} catch (err) {
|
||
log.warn('modo assistido: contexto do lead falhou — detecção cai na mensagem fixada', {
|
||
error: (err instanceof Error ? err.message : String(err)).slice(0, 160),
|
||
});
|
||
}
|
||
// O plano B NUNCA sobrescreve o contexto lido: ele existe só quando a leitura
|
||
// falhou, e aí a mensagem fixada no job é o que temos.
|
||
if (!contextoLido && args.inboundMessageId !== undefined) {
|
||
try {
|
||
const fixada = await loadInboundBodyForJob(pool, {
|
||
tenantId,
|
||
conversationId,
|
||
inboundMessageId: args.inboundMessageId,
|
||
});
|
||
if (fixada !== null && fixada.trim() !== '') inboundsPendentes = [fixada];
|
||
} catch (err) {
|
||
log.warn('modo assistido: mensagem fixada não lida — sem detecção neste turno', {
|
||
error: (err instanceof Error ? err.message : String(err)).slice(0, 160),
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Args do aviso, montados NO MOMENTO do uso — o canal nasce só se algo casar. */
|
||
const avisoDaEscalacao = (): {
|
||
ids: AvisoDeEscalacaoIds;
|
||
base: Omit<AvisoDeEscalacaoOpts, 'motivo'>;
|
||
} => ({
|
||
ids: {
|
||
tenantId,
|
||
leadId,
|
||
conversationId,
|
||
channelSessionId,
|
||
jobId: job.id,
|
||
jobClaim: claimOfJob(job),
|
||
},
|
||
base: {
|
||
channel: deps.channel
|
||
? deps.channel(pool)
|
||
: new WahaChannelAdapter(pool, { ...deps.crmCfg, agentActorId: agent.agentId }),
|
||
optedOutThisTurn,
|
||
now: clock(),
|
||
log,
|
||
...(lgpd !== undefined ? { lgpd } : {}),
|
||
agentId: agent.agentId,
|
||
...(deps.knobs.disclosureMode !== undefined
|
||
? { disclosureMode: deps.knobs.disclosureMode }
|
||
: {}),
|
||
...(deps.sleep !== undefined ? { sleep: deps.sleep } : {}),
|
||
},
|
||
});
|
||
|
||
// PLANO B SEM CONTEXTO = SEM `lgpd`: com ele nulo o gate de LGPD passa direto
|
||
// (`before-send.ts`), e um contato anonimizado receberia o aviso. Nesse ramo o
|
||
// aviso sai por `avisarLeadLendoOContato`, que lê o contato do banco.
|
||
const semInsumosDoContexto = ({
|
||
optedOutThisTurn: _bloqueado,
|
||
lgpd: _lgpd,
|
||
...resto
|
||
}: Omit<AvisoDeEscalacaoOpts, 'motivo'>): Omit<
|
||
AvisoDeEscalacaoOpts,
|
||
'motivo' | 'optedOutThisTurn' | 'lgpd'
|
||
> => resto;
|
||
|
||
if (
|
||
inboundsPendentes.some(
|
||
(texto) =>
|
||
detectHumanHandoffRequest(texto) ||
|
||
matchesHandoffKeyword(texto, agent.handoffKeywords),
|
||
)
|
||
) {
|
||
const briefing = montarBriefingDaPassagem({
|
||
checkpoint: await latestCheckpoint(pool, tenantId, leadId),
|
||
pendentesDoCliente: inboundsPendentes,
|
||
motivo: { codigo: 'requested_human' },
|
||
});
|
||
const aviso = avisoDaEscalacao();
|
||
const desfecho = contextoLido
|
||
? await avisarLeadDaEscalacao(pool, aviso.ids, { ...aviso.base, motivo: 'pediu_humano' })
|
||
: await avisarLeadLendoOContato(pool, aviso.ids, {
|
||
...semInsumosDoContexto(aviso.base),
|
||
motivo: 'pediu_humano',
|
||
});
|
||
await performHumanHandoff(
|
||
pool,
|
||
{ tenantId, leadId, conversationId },
|
||
{
|
||
reason: 'requested_human',
|
||
conversationSummary: briefing.body,
|
||
passagem: { origem: 'pedido_explicito', motivoCodigo: 'requested_human', briefing },
|
||
avisoAoLead: desfecho,
|
||
log,
|
||
},
|
||
);
|
||
log.info(
|
||
'handoff humano acionado por pedido explícito do lead (modo assistido, detecção determinística)',
|
||
{ kind: job.kind, lead_avisado: desfecho.avisado },
|
||
);
|
||
return true;
|
||
}
|
||
|
||
// STOP AMBÍGUO — o rascunho pendente NÃO segura este ramo: o pedido de parar
|
||
// vale por si, e é ele que cancela os follow-ups agendados (LGPD #1648).
|
||
if (inboundsPendentes.some((texto) => detectAmbiguousOptOut(texto))) {
|
||
const briefing = montarBriefingDaPassagem({
|
||
checkpoint: await latestCheckpoint(pool, tenantId, leadId),
|
||
pendentesDoCliente: inboundsPendentes,
|
||
motivo: { codigo: 'suspected_optout' },
|
||
});
|
||
const aviso = avisoDaEscalacao();
|
||
const desfecho = contextoLido
|
||
? await avisarLeadDaEscalacao(pool, aviso.ids, { ...aviso.base, motivo: 'suspeita_de_opt_out' })
|
||
: await avisarLeadLendoOContato(pool, aviso.ids, {
|
||
...semInsumosDoContexto(aviso.base),
|
||
motivo: 'suspeita_de_opt_out',
|
||
});
|
||
await performHumanHandoff(
|
||
pool,
|
||
{ tenantId, leadId, conversationId },
|
||
{
|
||
reason: 'suspected_optout',
|
||
conversationSummary: briefing.body,
|
||
inboxTitle: 'Suspeita de opt-out — confirmar bloqueio do contato no CRM',
|
||
passagem: { origem: 'opt_out_provavel', motivoCodigo: 'suspected_optout', briefing },
|
||
avisoAoLead: desfecho,
|
||
log,
|
||
},
|
||
);
|
||
log.info(
|
||
'possível opt-out detectado no modo assistido — bot silenciado, follow-ups cancelados e escalado ao humano',
|
||
{ kind: job.kind, lead_avisado: desfecho.avisado },
|
||
);
|
||
return true;
|
||
}
|
||
|
||
// Opt-out JÁ registrado na fonte (CRM): sem mensagem nova que casasse acima,
|
||
// mas os crons podem ter nascido depois do bloqueio. Idempotente — o mesmo
|
||
// cancel que o handoff acima compartilha (F4-07).
|
||
if (optedOutThisTurn) {
|
||
const canceled = await cancelPendingCronsForLead(pool, tenantId, leadId);
|
||
if (canceled > 0) {
|
||
log.info('opt-out já registrado — follow-ups agendados cancelados (modo assistido)', {
|
||
canceled,
|
||
});
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* O NÚCLEO DO TURNO, SEMPRE SOB A ESCOLTA DO ORÇAMENTO.
|
||
*
|
||
* Esta função é o único ponto do produto por onde os três kinds de turno de
|
||
* lead passam (`inbound_turn`, `followup_turn`, `case_reply_turn` — quatro call
|
||
* sites), e por isso é aqui que a escolta mora. Envolver o turno INTEIRO, e não
|
||
* as chamadas de modelo, é o que faz a proteção alcançar as chamadas indiretas
|
||
* (`classifyStage`, `maybeCompact`/flush) — que são justamente as PRIMEIRAS do
|
||
* turno, e portanto as que estouram primeiro quando o teto acabou. Ver o
|
||
* cabeçalho de `comHandoffSeOrcamentoAcabar` para o defeito medido.
|
||
*
|
||
* `executarTurnoDoAgente` NÃO é exportada de propósito: exportá-la criaria um
|
||
* caminho para o turno rodar desescoltado, e a guarda de artefato
|
||
* (`tests/unit/handoff-por-orcamento.test.ts`) conta exatamente um call site.
|
||
*/
|
||
export async function runAgentTurn(
|
||
deps: InboundTurnDeps,
|
||
job: JobRow,
|
||
pool: pg.Pool,
|
||
ctx: { workerId: string },
|
||
input: AgentTurnInput,
|
||
): Promise<void> {
|
||
const leadIdDoJob = job.contact_id;
|
||
if (leadIdDoJob === null) {
|
||
throw new Error('job de turno sem contact_id — o CHECK da fila deveria impedir');
|
||
}
|
||
const logDaEscolta = withFields(deps.log, {
|
||
job_id: job.id,
|
||
tenant_id: job.organization_id,
|
||
lead_id: leadIdDoJob,
|
||
});
|
||
await comHandoffSeOrcamentoAcabar(
|
||
{
|
||
pool,
|
||
tenantId: job.organization_id,
|
||
leadId: leadIdDoJob,
|
||
conversationId: input.conversationId,
|
||
briefingDoCheckpoint: () =>
|
||
briefingDoCheckpointDuravel(pool, job.organization_id, leadIdDoJob, logDaEscolta),
|
||
// O canal nasce DENTRO da closure: instanciá-lo aqui faria todo turno feliz
|
||
// pagar por um adapter que só o caminho de erro usa. Sem `agentActorId` de
|
||
// propósito — quando o teto estoura antes da primeira chamada, não houve
|
||
// agente resolvido para creditar.
|
||
avisarLead: () =>
|
||
avisarLeadLendoOContato(
|
||
pool,
|
||
{
|
||
tenantId: job.organization_id,
|
||
leadId: leadIdDoJob,
|
||
conversationId: input.conversationId,
|
||
channelSessionId: input.channelSessionId,
|
||
jobId: job.id,
|
||
},
|
||
{
|
||
motivo: 'orcamento_de_ia',
|
||
channel: (deps.channel ?? ((p: pg.Pool) => new WahaChannelAdapter(p, deps.crmCfg)))(
|
||
pool,
|
||
),
|
||
now: deps.clock?.() ?? new Date(),
|
||
log: logDaEscolta,
|
||
...(deps.knobs.disclosureMode !== undefined
|
||
? { disclosureMode: deps.knobs.disclosureMode }
|
||
: {}),
|
||
...(deps.sleep !== undefined ? { sleep: deps.sleep } : {}),
|
||
},
|
||
),
|
||
log: logDaEscolta,
|
||
},
|
||
() => executarTurnoDoAgente(deps, job, pool, ctx, input),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Este turno termina em mensagem PARA O LEAD? Só esses são adiados pela janela
|
||
* anti-ban — adiar os outros seria parar trabalho interno por causa de um
|
||
* horário que não é dele.
|
||
*
|
||
* * `inbound_turn` / `case_reply_turn` — respondem o cliente, sempre.
|
||
* * `followup_turn` — só com `purpose: 'send_message'`. Os outros dois
|
||
* propósitos do fluxo (`classify`, `plan_timing`) são leitura e
|
||
* planejamento: não abrem o WhatsApp de ninguém.
|
||
* * `operator_turn` — retaguarda (mexe no funil), nunca fala com o lead.
|
||
*/
|
||
/**
|
||
* ═══ RESPOSTA vs RETOMADA — a distinção que a janela da 0495 faz ═══
|
||
*
|
||
* `turnoVaiFalarComOLead` admite `followup_turn`, que RETOMA conversa parada — e
|
||
* retomar NÃO é responder: abrir `followup_turn` junto faria o número mandar
|
||
* "e aí, tudo certo?" às 4h para quem dormiu.
|
||
*
|
||
* Só a REAÇÃO a uma mensagem recebida lê a janela de resposta. `case_reply_turn`
|
||
* entra porque responde a um caso em aberto: ninguém "chama" um caso, o caso chama.
|
||
*/
|
||
export function eTurnoDeResposta(job: Pick<JobRow, 'kind'>): boolean {
|
||
return job.kind === 'inbound_turn' || job.kind === 'case_reply_turn';
|
||
}
|
||
|
||
function turnoVaiFalarComOLead(job: JobRow): boolean {
|
||
if (job.kind === 'inbound_turn' || job.kind === 'case_reply_turn') return true;
|
||
if (job.kind !== 'followup_turn') return false;
|
||
const purpose = (job.payload as { purpose?: unknown } | null)?.purpose;
|
||
return purpose === undefined || purpose === 'send_message';
|
||
}
|
||
|
||
async function executarTurnoDoAgente(
|
||
deps: InboundTurnDeps,
|
||
job: JobRow | null,
|
||
pool: pg.Pool,
|
||
ctx: { workerId: string },
|
||
input: AgentTurnInput,
|
||
preview?: TurnPreview,
|
||
): Promise<void> {
|
||
const liveJob = (): JobRow => {
|
||
if (!job) throw new Error('preview_operational_job_forbidden');
|
||
return job;
|
||
};
|
||
const tenantId = preview?.organizationId ?? liveJob().organization_id;
|
||
// Empty exists only in the in-memory scenario. Every CRM read uses preview's
|
||
// context and all operational tools are replaced before entering the loop.
|
||
const leadId = preview?.contactId ?? (preview ? '' : liveJob().contact_id);
|
||
if (leadId === null) throw new Error('turn_without_contact');
|
||
// O RELÓGIO, declarado antes de qualquer guarda de janela.
|
||
//
|
||
// Ele já existia — 330 linhas ABAIXO, depois das duas guardas que mais
|
||
// dependem dele. O contrato de `InboundTurnDeps.clock` diz "a janela horária
|
||
// do gate anti-ban é avaliada nele", e as duas guardas chamavam `new Date()`
|
||
// cru: o relógio injetado não alcançava justamente o que ele existe para
|
||
// fixar. Em produção dá no mesmo; no CI, a hora real do runner decidia, e a
|
||
// suíte de invariantes ficava vermelha das 22h às 7h (fuso do tenant) — nove
|
||
// horas por dia em que um PR reprova por causa do relógio de parede.
|
||
const clock = deps.clock ?? ((): Date => new Date());
|
||
const inicioDoProcessamento = performance.now();
|
||
const contextKnobs = {
|
||
historyLimit: deps.knobs.historyLimit,
|
||
maxTokens: deps.knobs.maxContextTokens,
|
||
};
|
||
// Contexto do RUN em toda linha de log do turno (F2-16): job_id É o run id.
|
||
const runLog = withFields(deps.log, {
|
||
job_id: job?.id ?? null,
|
||
preview_run_id: preview?.runId,
|
||
tenant_id: tenantId,
|
||
lead_id: leadId,
|
||
});
|
||
|
||
// AS DUAS CAMADAS QUE CUSTAM DINHEIRO, resolvidas UMA vez por turno.
|
||
//
|
||
// Os knobs (`deps.knobs.jailbreak`, `deps.knobs.promiseSemantic`) nascem no boot
|
||
// do worker e valem para a instalação inteira; a linha em `org_guardrail_layers`
|
||
// é a preferência de QUEM PAGA a consulta. Sem linha, `camadaLigada` devolve o
|
||
// padrão do ambiente — aplicar a migration não muda o comportamento de quem já
|
||
// decidiu no `.env`.
|
||
//
|
||
// Lido aqui, e não em cada ponto de uso: os dois consumidores ficam a ~900
|
||
// linhas de distância um do outro, e duas queries para a mesma pergunta viram,
|
||
// com o tempo, duas respostas.
|
||
const camadas = await lerCamadasDaOrg(pool, tenantId);
|
||
// O fuso da ORGANIZAÇÃO — o que o bloco `## Agora` usa lá embaixo, na montagem
|
||
// da abertura. Lido aqui pela mesma razão da linha acima: uma query por turno,
|
||
// longe do ponto de uso, para não virar duas respostas para a mesma pergunta.
|
||
// Nunca lança e nunca vem vazio (ver `fuso-da-org.ts`).
|
||
const fusoDaOrg = await fusoDaOrganizacao(pool, tenantId, runLog);
|
||
|
||
// F4-06 (acceptance 2): lead em handoff humano → NO-OP no INÍCIO do turno, antes de
|
||
// qualquer chamada de modelo/CRM. O bot silenciou (bot_silenced_until='infinity', cache
|
||
// do force_human do CRM) e só o humano/CRM libera — o agente nunca reassume (regra dura 2).
|
||
if (!preview && (await isLeadInHandoff(pool, tenantId, leadId))) {
|
||
runLog.info('turno pulado — lead em handoff humano (bot silenciado)', { kind: liveJob().kind });
|
||
return;
|
||
}
|
||
|
||
// GATE DE ELEGIBILIDADE (opt-in por canal — `metadata.ai_gate = 'allowlist'`).
|
||
// Segunda checagem, defesa em profundidade: o drain já barra antes de
|
||
// enfileirar, mas um job pode ter sido enfileirado quando a conversa ainda
|
||
// estava autorizada e um humano assumiu no meio-tempo, ou o gate do canal
|
||
// mudou. Canal 'open' (default) → `permite:true`, nada muda. NO-OP no início do
|
||
// turno, antes de qualquer chamada de modelo — mesmo lugar e mesmo custo do
|
||
// veto de handoff acima.
|
||
if (!preview) {
|
||
try {
|
||
const elegib = await decidirElegibilidadeDaConversa(pool, {
|
||
organizationId: tenantId,
|
||
conversationId: input.conversationId,
|
||
agora: clock(),
|
||
ttlMs: deps.knobs.allowlistTtlMs ?? ALLOWLIST_TTL_MS_PADRAO,
|
||
});
|
||
if (elegib !== null && !elegib.permite) {
|
||
runLog.info('turno pulado — conversa não elegível para IA', {
|
||
kind: liveJob().kind,
|
||
motivo: elegib.motivo,
|
||
});
|
||
return;
|
||
}
|
||
// KEEP-ALIVE: enquanto a conversa autorizada está viva, renova o carimbo —
|
||
// assim uma negociação de semanas não expira pela janela de validade, mas um
|
||
// contato que veio de uma submissão e sumiu volta a NÃO ser elegível depois
|
||
// da janela. Só no modo 'allowlist' (motivo 'autorizado'); fire-and-forget.
|
||
if (elegib !== null && elegib.motivo === 'autorizado' && liveJob().kind === 'inbound_turn') {
|
||
pool
|
||
.query(
|
||
`update contacts set ai_authorized_at = now()
|
||
where organization_id = $1 and id = $2 and ai_authorized_at is not null`,
|
||
[tenantId, leadId],
|
||
)
|
||
.catch((err: unknown) => {
|
||
runLog.warn('keep-alive da autorização de IA falhou', {
|
||
error: (err instanceof Error ? err.message : String(err)).slice(0, 120),
|
||
});
|
||
});
|
||
}
|
||
} catch (err) {
|
||
runLog.warn('checagem de elegibilidade falhou no turno — seguindo', {
|
||
error: (err instanceof Error ? err.message : String(err)).slice(0, 160),
|
||
});
|
||
}
|
||
}
|
||
|
||
// JANELA ANTI-BAN (7h–22h por padrão, fuso do tenant): fora dela o turno é
|
||
// ADIADO, não gasto.
|
||
//
|
||
// ⚠️ ISTO CONSERTA UMA MENSAGEM PERDIDA, não um custo. O gate de envio
|
||
// (`pacingGate` em guardrails/before-send.ts) já vetava o envio fora da
|
||
// janela — mas, no caminho do agente, esse veto vira ERRO DE ENSINO devolvido
|
||
// ao modelo dentro do tool `send_message`. O turno terminava `ok`, sem
|
||
// exceção, sem reagendamento e sem mensagem: o lead escrevia 22h e não recebia
|
||
// NADA, nem naquele momento nem às 7h. Medido em produção (2026-08-18): run
|
||
// `agent_turn` com status `ok` às 22:56 e zero outbound na conversa.
|
||
//
|
||
// O caminho determinístico de re-entrada já fazia o certo — "veto por JANELA
|
||
// anti-ban não dropa — re-agenda para a próxima abertura" (followup-turn.ts) —
|
||
// e é essa regra que passa a valer também para a resposta do agente.
|
||
//
|
||
// Só a JANELA adia. Cap diário e warm-up continuam com o gate de envio: eles
|
||
// dependem de quanto já saiu hoje, e antecipá-los aqui adiaria turno que, na
|
||
// hora do envio, teria passado.
|
||
if (!preview && turnoVaiFalarComOLead(liveJob())) {
|
||
const { knobs } = await loadChannelKnobs(pool, tenantId, input.channelSessionId, runLog);
|
||
const agora = clock();
|
||
const resposta = eTurnoDeResposta(liveJob());
|
||
// `resposta` separa as janelas: reação a quem escreveu lê `resposta*` (0495,
|
||
// que herda `window*` quando vazia); retomada e disparo leem `window*`.
|
||
if (!janelaDeEnvioAberta(agora, knobs, resposta)) {
|
||
const abertura = proximaAberturaDaJanela(agora, knobs, resposta);
|
||
await rescheduleJob(pool, liveJob().id, ctx.workerId, {
|
||
acquiredAt: claimOfJob(liveJob())?.acquired_at,
|
||
delayMs: Math.max(abertura.getTime() - agora.getTime(), 1_000),
|
||
reason: 'fora da janela anti-ban de envio — turno adiado para a abertura',
|
||
});
|
||
runLog.info('turno adiado — fora da janela anti-ban de envio', {
|
||
janela: `${resposta ? knobs.respostaStartHour : knobs.windowStartHour}h-${resposta ? knobs.respostaEndHour : knobs.windowEndHour}h`,
|
||
tipo: resposta ? 'resposta' : 'retomada',
|
||
timezone: knobs.timezone,
|
||
abertura: abertura.toISOString(),
|
||
});
|
||
// O adiamento deixa RASTRO VISÍVEL. Sem isto, o único registro de que o
|
||
// número está calado morre no log do contêiner — e foi assim que uma
|
||
// instalação passou um domingo inteiro muda, com todos os contêineres
|
||
// `healthy` e o dono sem nada para olhar. Um aviso por canal, deduplicado
|
||
// enquanto durar o silêncio; ver o cabeçalho de `aviso-de-janela.ts`.
|
||
//
|
||
// Fire-and-forget: telemetria nunca derruba um turno que já decidiu o que
|
||
// fazer com a mensagem do cliente — e o job JÁ foi reagendado acima.
|
||
try {
|
||
const criados = await avisarJanelaFechada(pool, {
|
||
tenantId,
|
||
channelSessionId: input.channelSessionId,
|
||
abertura,
|
||
janela: `${resposta ? knobs.respostaStartHour : knobs.windowStartHour}h-${resposta ? knobs.respostaEndHour : knobs.windowEndHour}h`,
|
||
timezone: knobs.timezone,
|
||
domingoDesligado: !knobs.allowSunday,
|
||
});
|
||
if (criados > 0) {
|
||
runLog.info('aviso de janela fechada aberto na Central', {
|
||
channel_session_id: input.channelSessionId,
|
||
});
|
||
}
|
||
} catch (err) {
|
||
runLog.warn('não consegui abrir o aviso de janela fechada', {
|
||
error: (err instanceof Error ? err.message : String(err)).slice(0, 120),
|
||
});
|
||
}
|
||
throw new JobSettledError(
|
||
'fora da janela anti-ban — job reagendado para a abertura da janela',
|
||
);
|
||
}
|
||
// A janela está ABERTA: se havia aviso de silêncio pendurado, ele morre
|
||
// AQUI — no mesmo ponto que o abriu. Um aviso que só o humano fecha vira
|
||
// dívida: na segunda o número volta a atender e o painel seguiria dizendo
|
||
// que está calado.
|
||
try {
|
||
await resolverAvisoDeJanela(pool, {
|
||
tenantId,
|
||
channelSessionId: input.channelSessionId,
|
||
});
|
||
} catch (err) {
|
||
runLog.warn('não consegui resolver o aviso de janela fechada', {
|
||
error: (err instanceof Error ? err.message : String(err)).slice(0, 120),
|
||
});
|
||
}
|
||
}
|
||
|
||
const routed = preview
|
||
? { config: preview.agent, routerId: null, intentName: null, confidence: null, outcome: 'preview' }
|
||
: input.resolvedAgent ?? await resolveConversationTurn(pool, deps.llmCfg, {
|
||
tenantId, leadId, jobId: liveJob().id,
|
||
channelSessionId: input.channelSessionId,
|
||
conversationId: input.conversationId,
|
||
inbound: liveJob().kind === 'inbound_turn',
|
||
}, { log: runLog, jev: deps.jev });
|
||
const agentConfig = routed.config;
|
||
if (
|
||
!preview &&
|
||
agentConfig?.operationMode === 'assisted' &&
|
||
job !== null &&
|
||
(job.kind === 'inbound_turn' || job.kind === 'followup_turn')
|
||
) {
|
||
// #1648 — as detecções DETERMINÍSTICAS (STOP/opt-out e pedido de humano)
|
||
// rodam ANTES do desvio para o rascunho. Elas não gastam token e não
|
||
// dependem do modo de operação, e o pedido de parar não pode esperar
|
||
// aprovação humana para ser registrado (LGPD). `true` = turno silenciado
|
||
// (aviso já enviado, silêncio durável e crons cancelados): aí não há
|
||
// rascunho o que rascunhar.
|
||
if (
|
||
await deteccoesDeterministicasDoAssistido(pool, deps, {
|
||
job,
|
||
tenantId,
|
||
conversationId: input.conversationId,
|
||
channelSessionId: input.channelSessionId,
|
||
leadId,
|
||
agent: agentConfig,
|
||
log: runLog,
|
||
...(input.inboundMessageId !== undefined
|
||
? { inboundMessageId: input.inboundMessageId }
|
||
: {}),
|
||
})
|
||
) {
|
||
return;
|
||
}
|
||
const { generateReplyDraft } = await import('./reply-drafts');
|
||
await generateReplyDraft(pool, deps, {
|
||
organizationId: tenantId,
|
||
conversationId: input.conversationId,
|
||
contactId: leadId,
|
||
channelId: input.channelSessionId,
|
||
boundary: currentExecutionBoundary() ?? undefined,
|
||
agent: agentConfig,
|
||
});
|
||
if (job.kind === 'followup_turn') {
|
||
// #1648 (3): o follow-up do assistido caía no `return` genérico de baixo
|
||
// e sumia — sem envio, sem rascunho e sem aviso. Vira rascunho, como o
|
||
// inbound: o humano vê, edita, aprova ou rejeita.
|
||
runLog.info('follow-up de agente assistido virou rascunho — nada sai sem aprovação', {
|
||
conversation_id: input.conversationId,
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
if (!preview && agentConfig && (agentConfig.pausedAt || agentConfig.operationMode === 'assisted'))
|
||
return;
|
||
const agentOperation =
|
||
!preview && agentConfig?.operationRevision
|
||
? {
|
||
organizationId: tenantId,
|
||
agentId: agentConfig.agentId,
|
||
versionId: agentConfig.versionId,
|
||
revision: agentConfig.operationRevision,
|
||
}
|
||
: undefined;
|
||
if (agentOperation) setExecutionAgentOperation(agentOperation);
|
||
if (agentConfig !== null) {
|
||
runLog.info('config do agente publicada em uso', {
|
||
agent_id: agentConfig.agentId,
|
||
agent_version_id: agentConfig.versionId,
|
||
model: agentConfig.model,
|
||
router_outcome: routed.outcome,
|
||
intent: routed.intentName,
|
||
});
|
||
}
|
||
// Horário de funcionamento da versão publicada (spec da tela: TriggerEditor).
|
||
// Vale SÓ para o turno inbound: a janela do lojista é sobre QUANDO ele atende
|
||
// quem chega, e adiar por ela um follow-up já prometido ao lead atrasaria uma
|
||
// promessa que não é dele. O que segura o follow-up é a janela anti-ban logo
|
||
// acima (`pacing/engine.ts`), que vale para os dois.
|
||
//
|
||
// Adia, não descarta: o job volta a 'pending' na abertura, SEM consumir
|
||
// attempts (`rescheduleJob`) — quem escreveu 22h é atendido às 8h. O throw é
|
||
// o contrato de `JobSettledError`: o run já dispôs do job, main.ts no-opa.
|
||
if (!preview && liveJob().kind === 'inbound_turn' && agentConfig?.janelaDeAtendimento != null) {
|
||
const esperaMs = msAteAJanelaAbrir(agentConfig.janelaDeAtendimento, clock());
|
||
if (esperaMs !== null) {
|
||
await rescheduleJob(pool, liveJob().id, ctx.workerId, {
|
||
acquiredAt: claimOfJob(liveJob())?.acquired_at,
|
||
delayMs: esperaMs,
|
||
reason:
|
||
'fora do horário de funcionamento do agente — turno adiado para a abertura da janela',
|
||
});
|
||
runLog.info(
|
||
'turno adiado — fora do horário de funcionamento configurado na versão publicada',
|
||
{
|
||
agent_id: agentConfig.agentId,
|
||
espera_ms: esperaMs,
|
||
janela: `${agentConfig.janelaDeAtendimento.start}-${agentConfig.janelaDeAtendimento.end}`,
|
||
},
|
||
);
|
||
throw new JobSettledError(
|
||
'fora do horário de funcionamento — job reagendado para a abertura da janela',
|
||
);
|
||
}
|
||
}
|
||
|
||
// Fase 3: grava a decisão de roteamento e a aderência da conversa ao agente.
|
||
// Fire-and-forget — falha de telemetria nunca derruba a resposta ao lead.
|
||
if (!preview && routed.routerId !== null) {
|
||
try {
|
||
if (agentConfig !== null) {
|
||
await pool.query(
|
||
`update conversations
|
||
set active_ai_agent_id = $3, active_intent = $4, active_agent_set_at = now()
|
||
where organization_id = $1 and id = $2`,
|
||
[tenantId, input.conversationId, agentConfig.agentId, routed.intentName],
|
||
);
|
||
}
|
||
// A intenção e a confiança que ROTEARAM — do classificador, ou do Jev
|
||
// quando a tarefa do roteador dele decide (`resolve-turn-agent.ts`). Quem
|
||
// decidiu mora em `llm_calls` do mesmo `job_id` (purpose `intent_router`,
|
||
// `origem_da_escolha = 'jev'`); o único leitor desta tabela
|
||
// (`app/api/v1/ai/evolution`) conta roteamento, não quem o fez.
|
||
await pool.query(
|
||
`insert into ai_router_decisions
|
||
(organization_id, router_id, conversation_id, intent_name, confidence, agent_id, outcome, job_id)
|
||
values ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
||
[
|
||
tenantId,
|
||
routed.routerId,
|
||
input.conversationId,
|
||
routed.intentName,
|
||
routed.confidence,
|
||
agentConfig?.agentId ?? null,
|
||
routed.outcome,
|
||
liveJob().id,
|
||
],
|
||
);
|
||
} catch (err) {
|
||
runLog.warn('decisão do router não gravada', {
|
||
error: (err instanceof Error ? err.message : String(err)).slice(0, 120),
|
||
});
|
||
}
|
||
}
|
||
// Knobs por-turno: a versão publicada vence o env; sem ela, env (main.ts).
|
||
const maxSteps = agentConfig?.maxSteps ?? deps.knobs.maxSteps;
|
||
// Fallback de modelo das chamadas AUXILIARES (classificadores/compaction/promessa):
|
||
// knob de env → modelo do agente PUBLICADO na tela → organizations.settings.llm.
|
||
// Sem isso, self-host que configurou tudo pela tela (que não preenche default_model)
|
||
// morria no primeiro classificador: "modelo LLM não definido".
|
||
// A regra de ONDE o classificador auxiliar tira modelo + provider + credencial
|
||
// mora em `aux-model-args.ts`, fora daqui, para poder ser exercitada por unit:
|
||
// esta função precisa de banco, job e registry para rodar. Ver o defeito que a
|
||
// originou (PR #151) no cabeçalho de lá.
|
||
const argsAux = (configuredModel: string | undefined): AuxModelArgs =>
|
||
auxModelArgs(configuredModel, agentConfig);
|
||
|
||
/**
|
||
* Os DOIS limites do histórico vêm da versão publicada — e o segundo vinha da
|
||
* env, que é o defeito.
|
||
*
|
||
* A tela oferece "Tamanho máximo desse histórico" por agente e grava
|
||
* `history_token_window` (default 8.000). O turno lia `historyMessageWindow`
|
||
* dali e `maxTokens` de `LEAD_CONTEXT_MAX_TOKENS`, uma env com default 1.000
|
||
* que sequer aparece no `.env.example`: quem configurava 8.000 na tela recebia
|
||
* 1.000, sem nada dizer que o número não valia. Metade da versão publicada era
|
||
* lida, metade não.
|
||
*
|
||
* O corte não morde na conversa curta de WhatsApp — ali quem limita é a janela
|
||
* de mensagens (20 por padrão). Ele morde exatamente onde dói: mensagem longa
|
||
* e áudio transcrito, quando o histórico é a única coisa que sustenta o fio da
|
||
* conversa. O ramo sem versão publicada segue com os knobs da instalação, que
|
||
* é o único caso em que ela é a fonte legítima.
|
||
*/
|
||
const turnContextKnobs =
|
||
agentConfig !== null
|
||
? {
|
||
historyLimit: agentConfig.historyMessageWindow,
|
||
maxTokens: agentConfig.historyTokenWindow,
|
||
}
|
||
: contextKnobs;
|
||
|
||
// Ritual de abertura: playbook por ponteiro + checkpoint + contexto curado.
|
||
// Com agente publicado, o system_prompt DELE é a camada tenant (platform de
|
||
// compliance continua à frente, sempre).
|
||
const prospectingContext = !preview && input.conversationId ? await prospectingConversationContext(pool, tenantId, input.conversationId) : "";
|
||
const playbook = await loadPlaybook(
|
||
pool,
|
||
tenantId,
|
||
agentConfig !== null ? { agentLayer: agentConfig.systemPrompt + prospectingContext } : undefined,
|
||
);
|
||
// Skills situacionais (F3-09): índice (name+description) SEMPRE residente — vai junto do
|
||
// system do playbook, no prefixo estável org-wide (disclosure progressivo; cacheável F2-17).
|
||
// O CORPO só carrega no match, no sufixo por-lead (mais abaixo). loadSkills resolve os
|
||
// ponteiros a cada run: trocar/rollback de skill = mover o ponteiro, sem restart.
|
||
const skills = await loadSkills(pool, tenantId);
|
||
const skillIndex = renderSkillIndex(skills);
|
||
// Fase 1 (harness): memória geral da org — prefixo estável, resolvida a cada
|
||
// turno como o playbook (publicar ⇒ próximo turno vale). composeSystemPrompt já
|
||
// encaixa playbook + memória + índice de skills no prefixo cacheável.
|
||
const orgMemory = await loadOrgMemory(pool, tenantId);
|
||
const systemWithMemory = composeSystemPrompt({
|
||
playbookPrompt: playbook.prompt,
|
||
orgMemoryBlock: renderOrgMemory(orgMemory),
|
||
skillIndex,
|
||
});
|
||
// Spec 15 §5.2: bloco das tools de caso SEMPRE residente (não invalida o prefixo
|
||
// cacheável — mesmo espírito do índice de skills) quando a tela habilita. O bloco da
|
||
// Agenda segue o mesmo padrão, condicionado a `crm_book_appointment` estar entre as
|
||
// tools publicadas — ver comentário de `agendaSystemBlock`. `TRANSPARENCIA_SYSTEM_BLOCK`
|
||
// não depende de nenhuma feature — todo agente publicado o recebe.
|
||
const blocosResidentes = [systemWithMemory, TRANSPARENCIA_SYSTEM_BLOCK];
|
||
if (agentConfig !== null && agentConfig.casesEnabled) blocosResidentes.push(CASES_SYSTEM_BLOCK);
|
||
// Spec 15 §5.2 / doutrina da Agenda: a régua é o que o agente TEM — ver
|
||
// `blocosDeAgendaResidentes`, que decidiu isto num lugar só para poder ser
|
||
// testada (o bloco da cadeia nomeia `crm_list_event_types`, e nomear
|
||
// ferramenta ausente faz o modelo tentar chamá-la).
|
||
if (agentConfig !== null) blocosResidentes.push(...blocosDeAgendaResidentes(agentConfig.toolIds));
|
||
if (preview)
|
||
blocosResidentes.push(
|
||
'MODO PRÉVIA: proponha a resposta com send_message. Operações são propostas separadas; nunca diga que executou uma proposta. Nenhum envio real acontece.',
|
||
);
|
||
const system = blocosResidentes.join('\n\n');
|
||
const previous = preview
|
||
? (preview.previous ?? null)
|
||
: await latestCheckpoint(pool, tenantId, leadId);
|
||
const leadState = preview?.kind === 'sandbox' ? null : await getLeadState(pool, tenantId, leadId);
|
||
const openingContext = preview
|
||
? preview.context
|
||
: await getLeadContext(
|
||
pool,
|
||
deps.crmCfg,
|
||
{ tenantId, leadId, conversationId: input.conversationId, fuso: fusoDaOrg },
|
||
turnContextKnobs,
|
||
);
|
||
if (!openingContext.ok) {
|
||
// Sem contexto não há turno: transiente (CRM fora) OU permanente (lead
|
||
// sumiu) — ambos re-tentam pela fila e morrem em 'dead' se persistirem.
|
||
throw new Error(`abertura do turno falhou em get_lead_context (${openingContext.error.code})`);
|
||
}
|
||
const currentInboundText =
|
||
input.inboundMessageId === undefined
|
||
? null
|
||
: await loadInboundBodyForJob(pool, {
|
||
tenantId,
|
||
conversationId: input.conversationId,
|
||
inboundMessageId: input.inboundMessageId,
|
||
});
|
||
|
||
// Seam de canal (F2-25): o envio vai SÓ pela interface ChannelAdapter — o
|
||
// default WAHA-via-CRM envolve o sink F2-06. Instanciado por job (o pool é
|
||
// per-job neste codebase); trocar o adapter não muda nada abaixo.
|
||
// Fase 2B: o envio carrega o ai_agents.id REAL como ator (audit/metadata do
|
||
// CRM apontam o agente publicado, não um id genérico).
|
||
//
|
||
// ⚠️ Ele nasce AQUI, e não depois da compactação como antes, porque os dois
|
||
// desvios determinísticos logo abaixo — pedido de humano e suspeita de
|
||
// opt-out — passaram a FALAR com o lead antes de silenciar. Eles rodam antes
|
||
// de qualquer chamada de modelo; o canal precisa existir antes deles.
|
||
const turnCrmCfg =
|
||
agentConfig !== null ? { ...deps.crmCfg, agentActorId: agentConfig.agentId } : deps.crmCfg;
|
||
const channel = preview
|
||
? null
|
||
: (deps.channel ?? ((p: pg.Pool) => new WahaChannelAdapter(p, turnCrmCfg)))(pool);
|
||
const liveChannel = (): ChannelAdapter => {
|
||
if (!channel) throw new Error('preview_transport_forbidden');
|
||
return channel;
|
||
};
|
||
// STOP lido no turno (fonte: CRM via get_lead_context) — combinado com o cache
|
||
// durável leads.is_opted_out no gate 1 da cadeia (F2-13).
|
||
const optedOutThisTurn = openingContext.context.contact.is_blocked;
|
||
// LGPD (F4-09): base legal/anonimização do CRM lidas na abertura do turno (fonte confiável,
|
||
// regra dura nº 1) — o gate LGPD da cadeia veta anonimizado (sempre) e 1º toque de prospecção
|
||
// sem base legal. Resposta a inbound (isProspecting=false) não dispara o veto de base legal.
|
||
const lgpd = openingContext.lgpd;
|
||
|
||
/** Argumentos fixos do aviso ao lead — os dois desvios abaixo só trocam o motivo. */
|
||
const avisoDaEscalacao = () => ({
|
||
ids: {
|
||
tenantId,
|
||
leadId,
|
||
conversationId: input.conversationId,
|
||
channelSessionId: input.channelSessionId,
|
||
jobId: liveJob().id,
|
||
jobClaim: claimOfJob(liveJob()),
|
||
agentOperation,
|
||
},
|
||
base: {
|
||
channel: liveChannel(),
|
||
optedOutThisTurn,
|
||
now: clock(),
|
||
log: runLog,
|
||
lgpd,
|
||
agentId: agentConfig?.agentId ?? null,
|
||
...(deps.knobs.disclosureMode !== undefined
|
||
? { disclosureMode: deps.knobs.disclosureMode }
|
||
: {}),
|
||
...(deps.sleep !== undefined ? { sleep: deps.sleep } : {}),
|
||
},
|
||
});
|
||
|
||
// F4-06 (acceptance 1): detecção DETERMINÍSTICA (regex PT-BR, sem LLM) de pedido explícito
|
||
// de atendimento humano na última mensagem do lead. Handoff é cidadão de 1ª classe (exigência
|
||
// Meta fiscalizada, blueprint 5.5) — dispara ANTES do modelo: o bot não gasta LLM.
|
||
// A ação (CRM force_human + cache + cancela crons + inbox) é idempotente.
|
||
//
|
||
// ⚠️ ORDEM: AVISA e SÓ ENTÃO silencia. Não é preferência de redação — é a única
|
||
// ordem que funciona. `performHumanHandoff` grava `contacts.force_human = true`,
|
||
// e o gate 1 da cadeia (`stopGate`) lê `(is_blocked or force_human)` DIRETO da
|
||
// fonte, sob o lock, a cada tentativa de envio. Avisar depois seria avisar
|
||
// ninguém: a própria trava que a passagem acabou de armar veta a mensagem.
|
||
// DUAS perguntas diferentes, duas fontes diferentes — e emendá-las foi o dano
|
||
// colateral medido do pin.
|
||
//
|
||
// • `mensagemDoJob` é O QUE ESTE TURNO RESPONDE. Vem pinada no
|
||
// `inbound_message_id`, para um registro concorrente não sequestrar o turno.
|
||
// • `inboundsPendentes` é O QUE O CLIENTE DISSE e ainda não foi respondido.
|
||
// Handoff, opt-out e urgência leem daqui: são coisas que não podem passar
|
||
// despercebidas só porque chegaram na segunda mensagem de uma rajada, que o
|
||
// drain coalesce no job da primeira.
|
||
const mensagemDoJob =
|
||
currentInboundText ?? latestInboundSignal(openingContext.context.messages);
|
||
const inboundsPendentes = inboundsNaoRespondidos(openingContext.context.messages);
|
||
if (
|
||
!preview &&
|
||
inboundsPendentes.some(
|
||
(texto) =>
|
||
detectHumanHandoffRequest(texto) ||
|
||
(agentConfig !== null && matchesHandoffKeyword(texto, agentConfig.handoffKeywords)),
|
||
)
|
||
) {
|
||
const aviso = await avisarLeadDaEscalacao(pool, avisoDaEscalacao().ids, {
|
||
...avisoDaEscalacao().base,
|
||
motivo: 'pediu_humano',
|
||
});
|
||
// `inboundsPendentes` JÁ está em memória (linha acima): a fala literal do
|
||
// cliente entra no briefing a custo zero. É a diferença entre quem assume
|
||
// ler "o cliente pediu uma pessoa" e ler o que ele de fato escreveu.
|
||
const briefing = montarBriefingDaPassagem({
|
||
checkpoint: previous,
|
||
pendentesDoCliente: inboundsPendentes,
|
||
motivo: { codigo: 'requested_human' },
|
||
});
|
||
await performHumanHandoff(
|
||
pool,
|
||
{ tenantId, leadId, conversationId: input.conversationId },
|
||
{
|
||
reason: 'requested_human',
|
||
conversationSummary: briefing.body,
|
||
passagem: { origem: 'pedido_explicito', motivoCodigo: 'requested_human', briefing },
|
||
avisoAoLead: aviso,
|
||
log: runLog,
|
||
},
|
||
);
|
||
runLog.info('handoff humano acionado por pedido explícito do lead (detecção determinística)', {
|
||
kind: liveJob().kind,
|
||
lead_avisado: aviso.avisado,
|
||
});
|
||
return; // bot silencia: o aviso já saiu, e nada mais sai neste turno
|
||
}
|
||
|
||
// F4-07: STOP AMBÍGUO ("para de me mandar isso", "não quero mais receber", "me tira da
|
||
// lista", ou a palavra-chave STOP/PARAR/SAIR sozinha). Detecção CONSERVADORA — na dúvida
|
||
// é STOP: o bot silencia JÁ (sem LLM, sem envio) via o MESMO mecanismo durável do handoff
|
||
// (bot_silenced_until='infinity', que SOBREVIVE à leitura do CRM que sobrescreve o cache
|
||
// is_opted_out) e escala à inbox para o humano confirmar o opt-out real (is_blocked) no
|
||
// CRM. Cancela os follow-ups agendados de tabela. Nada disso reverte (regra dura nº 2).
|
||
if (!preview && inboundsPendentes.some((texto) => detectAmbiguousOptOut(texto))) {
|
||
// O aviso daqui NÃO fala em atendente — quem pediu para parar não quer ouvir
|
||
// sobre atendimento (`textoDoAviso`, motivo `suspeita_de_opt_out`). Ele
|
||
// CONFIRMA a parada, que é o padrão de mensageria para um opt-out, e diz que
|
||
// uma pessoa vai conferir. Sair calado deixaria a pessoa sem saber se o
|
||
// pedido dela foi ouvido — e ela pediu justamente para ser ouvida.
|
||
const aviso = await avisarLeadDaEscalacao(pool, avisoDaEscalacao().ids, {
|
||
...avisoDaEscalacao().base,
|
||
motivo: 'suspeita_de_opt_out',
|
||
});
|
||
const briefing = montarBriefingDaPassagem({
|
||
checkpoint: previous,
|
||
pendentesDoCliente: inboundsPendentes,
|
||
motivo: { codigo: 'suspected_optout' },
|
||
});
|
||
await performHumanHandoff(
|
||
pool,
|
||
{ tenantId, leadId, conversationId: input.conversationId },
|
||
{
|
||
reason: 'suspected_optout',
|
||
conversationSummary: briefing.body,
|
||
inboxTitle: 'Suspeita de opt-out — confirmar bloqueio do contato no CRM',
|
||
passagem: { origem: 'opt_out_provavel', motivoCodigo: 'suspected_optout', briefing },
|
||
avisoAoLead: aviso,
|
||
log: runLog,
|
||
},
|
||
);
|
||
runLog.info('possível opt-out detectado no inbound — bot silenciado e escalado ao humano', {
|
||
kind: liveJob().kind,
|
||
lead_avisado: aviso.avisado,
|
||
});
|
||
return; // bot silencia: a confirmação já saiu, e nada mais sai neste turno
|
||
}
|
||
|
||
// ROTEIRO DE ATENDIMENTO (módulo opcional `fluxos_atendimento`, #1130). Entra
|
||
// AQUI, depois de tudo que silencia o turno — handoff humano, pausa, pedido de
|
||
// humano, opt-out ambíguo — e nunca para contato bloqueado. Na prova prática,
|
||
// o roteiro começava antes dessas travas e abria para quem pedira para parar.
|
||
// Chave desligada: `null` sem consulta nenhuma. Ver `roteiro-no-turno.ts`.
|
||
const roteiro =
|
||
!preview && liveJob().kind === 'inbound_turn' && !optedOutThisTurn
|
||
? await prepararRoteiroDoTurno(
|
||
{
|
||
pool,
|
||
moduloLigado: () => moduloLigadoComMemo(deps.crmCfg.supabase, 'fluxos_atendimento'),
|
||
validar: (args) =>
|
||
validarRespostaDoFluxo(
|
||
pool,
|
||
deps.llmCfg,
|
||
{ tenantId, leadId, jobId: liveJob().id },
|
||
args,
|
||
{ registry: deps.registry, log: runLog, aux: argsAux(undefined) },
|
||
),
|
||
log: runLog,
|
||
},
|
||
{
|
||
organizationId: tenantId,
|
||
contactId: leadId,
|
||
conversationId: input.conversationId,
|
||
texto: currentInboundText,
|
||
messageId: input.inboundMessageId ?? null,
|
||
flowPointerDoRoteador: 'flowPointerId' in routed ? (routed.flowPointerId ?? null) : null,
|
||
mensagens: openingContext.context.messages.slice(-6).map((m) => ({
|
||
de: m.direction === 'inbound' ? ('cliente' as const) : ('loja' as const),
|
||
texto: m.body,
|
||
})),
|
||
},
|
||
)
|
||
: null;
|
||
|
||
// F3-07: compaction + flush pré-compaction. Quando o histórico cresce além do limiar,
|
||
// o FLUSH grava as notas duráveis (lead_notes) e a compaction resume a conversa com o
|
||
// modelo BARATO; o resumo compactado entra no lugar do rolling summary e o transcript
|
||
// integral é trocado por uma cauda recente sob orçamento (regra de cache 15). O rolling
|
||
// summary DURÁVEL segue vindo do checkpoint de fechamento; aqui ele só alimenta o prompt.
|
||
let effectivePrevious = previous;
|
||
let effectiveContext = openingContext.context;
|
||
if (deps.knobs.compaction !== undefined) {
|
||
const compacted = await maybeCompact(
|
||
pool,
|
||
deps.llmCfg,
|
||
{ tenantId, leadId: leadId || null, jobId: job?.id },
|
||
{
|
||
context: openingContext.context,
|
||
previousSummary: previous?.rolling_summary ?? '',
|
||
// A compactação é o QUARTO call site da mesma regra, e o #151 só cobriu
|
||
// três: ela também pedia o modelo do agente ao provider default da org.
|
||
// Mesmo 404, mesma morte de turno — só que num caminho que roda quando a
|
||
// conversa já é longa, ou seja, mais tarde e com menos gente olhando.
|
||
knobs: { ...deps.knobs.compaction, ...argsAux(deps.knobs.compaction.model) },
|
||
notesIndexMaxTokens: deps.knobs.notesIndexMaxTokens,
|
||
},
|
||
{
|
||
registry: deps.registry,
|
||
log: runLog,
|
||
...(preview
|
||
? {
|
||
noteSink: (note: { headline: string; body: string }) => {
|
||
(preview.notes ??= []).push(note);
|
||
},
|
||
}
|
||
: {}),
|
||
},
|
||
);
|
||
if (compacted !== null) {
|
||
// Só o rolling_summary é sobrescrito (o resumo compactado carrega compromissos/
|
||
// objeções/estágio/dados pessoais planificados). O `previous` sintético do 1º
|
||
// turno com histórico importado é local — nunca persistido; o fechamento grava o
|
||
// checkpoint real.
|
||
const base: LeadCheckpointRow = previous ?? {
|
||
id: '',
|
||
seq: '0',
|
||
organization_id: tenantId,
|
||
contact_id: leadId,
|
||
job_id: null,
|
||
created_at: new Date(),
|
||
commitments: [],
|
||
objections: [],
|
||
next_action: null,
|
||
rolling_summary: '',
|
||
// Este `previous` é sintetizado a partir de histórico IMPORTADO — não
|
||
// houve turno nosso, logo ninguém declarou nada. `null` é o valor
|
||
// honesto; um objeto vazio afirmaria uma avaliação que não aconteceu.
|
||
declaracao: null,
|
||
};
|
||
effectivePrevious = { ...base, rolling_summary: renderCompactedSummary(compacted) };
|
||
effectiveContext = {
|
||
...openingContext.context,
|
||
messages: trimTranscriptToBudget(
|
||
openingContext.context.messages,
|
||
deps.knobs.compaction.transcriptMaxTokens,
|
||
),
|
||
};
|
||
}
|
||
}
|
||
|
||
// Índice da memória durável do lead (F3-05) — headlines dentro do orçamento fixo,
|
||
// injetado no SUFIXO da abertura (não invalida o prefixo cacheável F2-17). Montado
|
||
// DEPOIS do flush (F3-07) para que as notas gravadas neste turno já entrem no índice.
|
||
const notesIndexBlock = preview
|
||
? (preview.notes ?? []).map((n) => n.headline + ': ' + n.body).join('\n')
|
||
: await buildNotesIndexBlock(pool, tenantId, leadId, deps.knobs.notesIndexMaxTokens);
|
||
// ⚠️ `leadId` AQUI É O CONTATO (`leadIdDoJob = job.contact_id`, e o comentário
|
||
// de `get-lead-context.ts:193` diz o mesmo). Passar essa variável para um
|
||
// parâmetro chamado `contactId` é correto pelo VALOR; o nome é que mente, e é
|
||
// o que a issue #509 conserta. Não troque por um `lead_id` "mais coerente".
|
||
const compromissosBlock =
|
||
preview?.kind === 'sandbox'
|
||
? ''
|
||
: await buildCompromissosBlock(pool, tenantId, leadId, new Date());
|
||
// Observabilidade da memória (Fase 2A): SÓ ids/contagens no log — headline/corpo
|
||
// são PII e nunca saem do prompt. Prova auditável de que a memória durável do
|
||
// lead entrou no contexto DESTE turno.
|
||
if (!preview) {
|
||
const { rows: noteIdRows } = await pool.query<{ id: string }>(
|
||
'select id from lead_notes where organization_id = $1 and contact_id = $2 order by created_at',
|
||
[tenantId, leadId],
|
||
);
|
||
runLog.info('memória do lead injetada no turno', {
|
||
checkpoint_seq: effectivePrevious?.seq ?? null,
|
||
notes_count: noteIdRows.length,
|
||
note_ids: noteIdRows.map((r) => r.id),
|
||
});
|
||
}
|
||
|
||
// F4-07: STOP no CRM detectado no turno → cancela TODOS os follow-ups agendados do lead
|
||
// (não só o job atual). O stopGate já veta ESTE turno; o cancel garante que nenhum cron
|
||
// futuro dispare em vão (opt-out irrevogável, regra dura nº 2). Idempotente — reusa o
|
||
// cancel compartilhado com o handoff (F4-06).
|
||
if (!preview && optedOutThisTurn) {
|
||
const canceled = await cancelPendingCronsForLead(pool, tenantId, leadId);
|
||
if (canceled > 0) {
|
||
runLog.info('opt-out detectado no turno — follow-ups agendados cancelados', { canceled });
|
||
}
|
||
}
|
||
|
||
// Estado do RUN — vive só neste closure (isolamento por construção, acc 3).
|
||
let seq = 0;
|
||
// O que o modelo de fato mandou neste turno (depois da cadeia). A trava "a
|
||
// pergunta saiu?" do roteiro de atendimento lê daqui.
|
||
const corposEnviados: string[] = [];
|
||
// #1943: o turno foi DESCARTADO como obsoleto (guard `resposta_obsoleta`, #1940)
|
||
// ainda no run — o cliente escreveu de novo enquanto o modelo pensava. Quando
|
||
// ligada, NADA mais sai dele, nem a pergunta pendente do roteiro (que segue
|
||
// feita para o turno da mensagem nova). Ver `perguntaDoRoteiroPodeSair`.
|
||
let turnoDescartado = false;
|
||
// Teto de mensagens físicas por turno (F2-15b) — `seq` JÁ é a contagem certa: ele só
|
||
// avança quando o envio de fato sai pro canal (send_message + send_template, bolhas
|
||
// incluídas), nunca em veto de gate. Checar `seq` antes de tentar o próximo envio
|
||
// barra o modelo sem gastar uma chamada de before-send à toa.
|
||
const maxSendsPerTurn = deps.knobs.maxSendsPerTurn ?? DEFAULT_MAX_SENDS_PER_TURN;
|
||
// F3-11: estágio que o MODELO confirmou via update_lead_state neste turno (a máquina
|
||
// F2-10 é a única porta). Comparado com a sugestão do classificador no fim → divergência.
|
||
let confirmedStage: LeadStage | null = null;
|
||
// F4-04: a tabela de promessa versionada do tenant (F4-01), carregada uma vez para
|
||
// correlacionar tentativa de promessa fora de tabela com o sinal de jailbreak — a
|
||
// detecção NÃO depende do gate estar na cadeia default (a ordem final é da F4-08).
|
||
const promiseTable = (await loadPromiseTable(pool, tenantId))?.table ?? null;
|
||
// Gate 5 da cadeia (F4-02/F4-08): closure do classificador semântico com tenant/lead/job da
|
||
// ROW do job fechados dentro (regra dura nº 1) — resolvido pelo seam agnóstico. undefined =
|
||
// camada off (gate no-op). CUSTO: uma chamada de modelo POR ENVIO quando ligada.
|
||
const semanticClassifier = camadaLigada(
|
||
camadas.promessa_semantica,
|
||
deps.knobs.promiseSemantic?.enabled === true,
|
||
)
|
||
? (candidate: string) =>
|
||
classifyPromise(
|
||
pool,
|
||
deps.llmCfg,
|
||
{ tenantId, leadId: leadId || null, jobId: job?.id },
|
||
{
|
||
candidate,
|
||
...argsAux(deps.knobs.promiseSemantic?.model),
|
||
},
|
||
{ ...(deps.registry !== undefined ? { registry: deps.registry } : {}), log: runLog },
|
||
)
|
||
: undefined;
|
||
let outOfTablePromiseAttempted = false;
|
||
// Spec 15 (Wave 4 lê este flag): true quando open_human_case abriu um caso NESTE
|
||
// turno — aqui só declara e seta; o consumo (ex.: guardrail de promessa) é da Wave 4.
|
||
let openedCaseThisTurn = false;
|
||
// Wave 4 — contador do fail-safe do guardrail anti-alucinação (case_promise_without_case):
|
||
// 1º veto no turno é erro-de-ensino (o modelo re-tenta); persistir uma 2ª vez aciona o
|
||
// auto-abre-caso (ver send_message.execute). Por turno (closure), nunca cross-turno.
|
||
let casePromiseVetoCount = 0;
|
||
// Contador do fail-safe do gate de vazamento de vocabulário interno
|
||
// (`internal_vocabulary_leak`): 1º veto no turno ensina o modelo a reescrever; persistir
|
||
// solta o envio com registro. Por turno (closure), nunca cross-turno.
|
||
let internalVocabularyVetoCount = 0;
|
||
// Uma recusa deste tipo devolve o texto confirmado ao modelo para que ele
|
||
// reescreva antes de falar com o cliente. Não gasta envio nem toca no canal.
|
||
let falseEmptyInboundVetoCount = 0;
|
||
// A pausa humana (atraso-humano.ts) já foi paga NESTE turno? Por turno
|
||
// (closure), como os contadores acima. O turno pode passar pela cadeia
|
||
// `before_send` mais de uma vez — o modelo pode chamar `send_message` várias
|
||
// vezes, e os fail-safes de promessa/vocabulário re-rodam a cadeia inteira.
|
||
// Sem este flag, cada passagem cobraria do cliente uma espera nova, e um
|
||
// turno com dois vetos ficaria mudo por mais de 20 segundos: o conserto do
|
||
// "rápido demais" viraria o defeito simétrico, mais caro que o original.
|
||
let jaEsperouComoHumano = false;
|
||
// Knobs de atraso humano por conexão (0499) — lidos UMA vez por turno para a
|
||
// pausa antes da 1ª bolha (`esperarComoHumano`) e para o jitter entre bolhas
|
||
// (`throttle_ms + jitter_max_ms`). Sem linha em channel_knobs caem nos defaults
|
||
// de defaults.ts — que espelham os valores históricos (regressão zero).
|
||
const pacingDoTurno = !preview
|
||
? await loadChannelKnobs(pool, tenantId, input.channelSessionId, runLog)
|
||
: null;
|
||
const knobsDeAtrasoHumano = {
|
||
atrasoNotarMs: pacingDoTurno?.knobs.atrasoNotarMs,
|
||
msPorCaractere: pacingDoTurno?.knobs.msPorCaractere,
|
||
atrasoMinimoMs: pacingDoTurno?.knobs.atrasoMinimoMs,
|
||
atrasoMaximoMs: pacingDoTurno?.knobs.atrasoMaximoMs,
|
||
};
|
||
// Cap de envio (warm-up/diário) vetado neste turno — capturado aqui porque o veto
|
||
// não empurra outcome nenhum a `outcomes` (ver comentário no ponto de captura, mais
|
||
// abaixo). Diferente da janela horária (checada ANTES do modelo rodar, linha ~1233):
|
||
// o cap depende de quanto já saiu HOJE, que muda com o turno concorrente — só dá pra
|
||
// saber com certeza no momento do envio, não antes.
|
||
let pacingCapVeto: { code: string; nextAllowedAt: Date } | null = null;
|
||
// Best-effort: move o lead pra etapa `crm_stages.slug='chamar-humano'` do pipeline
|
||
// dele (se o tenant tiver criado essa etapa — opt-in, ver `lib/leads/handoff-stage-move.ts`)
|
||
// sempre que um caso humano abre neste turno, deliberado (open_human_case) ou pelo
|
||
// fail-safe do `case_promise`. Sem isto, o funil no CRM não refletia o handoff que o
|
||
// PRÓPRIO PROMPT do tenant promete ao lead ("vou verificar/encaminhar com o Fulano")
|
||
// — medido num tenant de produção: caso aberto, funil parado em "Novo contato".
|
||
// Nunca bloqueia nem derruba o turno — mesma disciplina de `triggerHandoff` (G1-G4),
|
||
// que já chama o mesmo helper para o handoff por palavra-chave do cliente.
|
||
const moverParaHandoffBestEffort = (reason: string): void => {
|
||
moverLeadParaEtapaDeHandoff(createAdminClient(), {
|
||
organizationId: tenantId,
|
||
leadId,
|
||
reason,
|
||
}).catch((err) => {
|
||
runLog.warn('moverLeadParaEtapaDeHandoff falhou (best-effort, caso humano)', {
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
});
|
||
};
|
||
// Arma o `agendaStallGate` (before-send.ts): true assim que crm_find_free_slots,
|
||
// crm_book_appointment ou crm_reschedule_appointment executar neste turno — marcado no
|
||
// wrapper das tools MCP, mais abaixo. `send_message` lê o valor NO MOMENTO do envio; como
|
||
// as tools do modelo rodam em passos anteriores do mesmo loop, o valor já está certo
|
||
// quando o modelo decide mandar a resposta.
|
||
let agendaToolCalledThisTurn = false;
|
||
const outcomes: ChannelSendResult[] = [];
|
||
// Citações acumuladas por buscas de conhecimento DESTE turno — anexadas à
|
||
// próxima outbound enviada (shape de lib/ai/citations/types, que a UI já lê).
|
||
let pendingCitations: ReturnType<typeof citationsFromHits> = [];
|
||
let runError: Error | null = null;
|
||
const noteRunError = (err: Error): void => {
|
||
runError ??= err;
|
||
};
|
||
|
||
// Guideline-matching if-then (F3-09): o SINAL do turno (última mensagem inbound) decide
|
||
// quais skills disparam. Corpos casados vão no SUFIXO da abertura (situacional, por-lead —
|
||
// depois do prefixo cacheável); situação neutra ⇒ nenhum corpo (economia de tokens). Os
|
||
// near-misses (probe sem hard-match) viram candidatos ao golden set, gravados como LINHA
|
||
// em `golden_candidates` (sem texto de cliente, issue #1695) — só com a knob ligada.
|
||
// Calculado AQUI, ANTES de
|
||
// montar rawTools (Fase 2): o gate de read_skill_reference precisa do resultado do match
|
||
// para decidir se a tool entra no turno (mesmo padrão de gate de search_knowledge/
|
||
// request_human_handoff, feito antes do wrapToolsWithBreaker).
|
||
// Sinal do matcher com o CONTEXTO recente (não só a última mensagem): a
|
||
// conversa sobre motos continua e a skill não pode "cair" quando o cliente
|
||
// responde a escolha ("A 2025"), senão as fotos da moto escolhida não saem.
|
||
//
|
||
// SÓ o matcher lê a janela. `skillSignal` segue sendo a ÚLTIMA inbound: ele
|
||
// também alimenta o classificador de jailbreak e os candidatos de divergência
|
||
// de estágio, e uma tentativa de jailbreak de cinco mensagens atrás não pode
|
||
// seguir marcando todo turno seguinte.
|
||
const skillSignal = latestInboundSignal(effectiveContext.messages);
|
||
const sinalDoMatcher = recentInboundSignal(effectiveContext.messages);
|
||
const skillMatch = matchSkills(skills, sinalDoMatcher);
|
||
// Skills que o roteiro puxa neste passo entram JUNTO do match por palavra
|
||
// (o nó `skill` diz "puxe isto aqui"). O match vence o empate por nome.
|
||
const skillsDoRoteiro = (roteiro?.skills ?? [])
|
||
.map((nome) => skills.find((sk) => sk.name === nome))
|
||
.filter((sk): sk is (typeof skills)[number] => sk !== undefined)
|
||
.filter((sk) => !skillMatch.matched.some((m) => m.name === sk.name));
|
||
const matchedSkillsBlock = renderMatchedSkillBodies([...skillMatch.matched, ...skillsDoRoteiro]);
|
||
if (!preview && deps.knobs.goldenCandidates === true) {
|
||
await recordSkillMissCandidates(
|
||
pool,
|
||
{
|
||
tenantId,
|
||
leadId,
|
||
jobId: liveJob().id,
|
||
candidates: skillMatch.missCandidates,
|
||
},
|
||
runLog,
|
||
);
|
||
}
|
||
// Fase 2: telemetria de ativação de skill (hard match + near-miss probe).
|
||
if (!preview) {
|
||
try {
|
||
const rows: Array<[string, string | null, string]> = [
|
||
...skillMatch.matched.map(
|
||
(s) => [s.name, s.versionId, 'hard'] as [string, string | null, string],
|
||
),
|
||
...skillMatch.missCandidates.map(
|
||
(m) => [m.skill, null, 'probe'] as [string, string | null, string],
|
||
),
|
||
];
|
||
if (rows.length > 0) {
|
||
const values: string[] = [];
|
||
const params: unknown[] = [];
|
||
rows.forEach(([name, verId, trig], i) => {
|
||
const b = i * 5;
|
||
values.push(`($${b + 1},$${b + 2},$${b + 3},$${b + 4},$${b + 5})`);
|
||
params.push(tenantId, name, verId, trig, liveJob().id);
|
||
});
|
||
await pool.query(
|
||
`insert into skill_activations (organization_id, skill_name, skill_version_id, trigger, job_id) values ${values.join(',')}`,
|
||
params,
|
||
);
|
||
}
|
||
} catch (err) {
|
||
runLog.warn('skill_activations não gravadas', {
|
||
error: (err instanceof Error ? err.message : String(err)).slice(0, 120),
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Os ids de catálogo que de fato ENTRARAM neste turno — não os que a tela
|
||
* marcou. A diferença importa: quando a montagem falha, o turno segue sem elas,
|
||
* e é o turno REAL que decide se a projeção arma. Ler a config aqui faria a
|
||
* projeção ficar desligada num turno que, por acidente, não recebeu ferramenta
|
||
* nenhuma — justo o turno em que ela é gratuita.
|
||
*
|
||
* Declarado ANTES de `rawTools` de propósito: o `execute` de `get_lead_context`
|
||
* fecha sobre ele e o lê no momento da CHAMADA, quando as ferramentas de
|
||
* catálogo já foram montadas (o `push` acontece bem abaixo, antes do loop do
|
||
* modelo). Deixá-lo declarado depois funcionaria, mas esconderia a ordem de que
|
||
* a correção depende.
|
||
*/
|
||
const mcpToolIdsDoTurno: string[] = [];
|
||
|
||
const rawTools: ToolSet = {
|
||
get_lead_context: tool({
|
||
...AGENT_TOOL_DEFS.get_lead_context,
|
||
execute: async (): Promise<
|
||
| LeadContextResult
|
||
// A variante PROJETADA é um tipo próprio, não um `LeadContext` disfarçado
|
||
// por cast: são payloads diferentes, e um `as` aqui faria o compilador
|
||
// parar de vigiar exatamente a fronteira que este código existe para
|
||
// manter. Note que `lgpd` não viaja nela — base legal e anonimização são
|
||
// dado de conformidade que o runtime usa nos gates, e que o modelo nunca
|
||
// precisou ler (no caminho não-projetado ele já ia junto; aqui para).
|
||
| { ok: true; context: ContextoProjetado; tokenCount: number }
|
||
| { ok: false; error: { code: string; message: string } }
|
||
> => {
|
||
try {
|
||
const releitura = preview
|
||
? preview.context
|
||
: await getLeadContext(
|
||
pool,
|
||
deps.crmCfg,
|
||
{ tenantId, leadId, conversationId: input.conversationId, fuso: fusoDaOrg },
|
||
turnContextKnobs,
|
||
);
|
||
// Sem esta linha a projeção da abertura seria decorativa: bastaria o
|
||
// modelo chamar esta ferramenta para receber o contexto CRU de volta,
|
||
// com `lead_id`, `conversation_id` e caminho de mídia. A releitura é a
|
||
// mesma superfície da abertura e tem de obedecer à mesma regra —
|
||
// proteger só a porta da frente é não ter protegido.
|
||
if (releitura.ok && turnoProjeta(mcpToolIdsDoTurno)) {
|
||
return {
|
||
ok: true,
|
||
context: projetarContexto(releitura.context),
|
||
tokenCount: releitura.tokenCount,
|
||
};
|
||
}
|
||
return releitura;
|
||
} catch (err) {
|
||
// bug de programação: ensina o modelo a encerrar E derruba o job no fim
|
||
noteRunError(err instanceof Error ? err : new Error(String(err)));
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'internal_error',
|
||
message: 'erro interno ao ler o contexto — encerre o turno agora.',
|
||
},
|
||
};
|
||
}
|
||
},
|
||
}),
|
||
send_template: tool({
|
||
...AGENT_TOOL_DEFS.send_template,
|
||
execute: async ({ template_name, language, values }) => {
|
||
if (seq >= maxSendsPerTurn) {
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'max_sends_per_turn',
|
||
message:
|
||
`você já enviou ${seq} mensagens neste turno (teto: ${maxSendsPerTurn}). ` +
|
||
'NÃO envie mais nada agora — encerre o turno e espere a resposta do lead.',
|
||
},
|
||
};
|
||
}
|
||
// O texto RENDERIZADO vai como `body` da cadeia: os gates de promessa,
|
||
// spinning e disclosure avaliam exatamente o que o contato vai ler. Sem
|
||
// isso, "usar template" seria a forma de escapar dos guardrails de conteúdo.
|
||
// A definição DESTA conexão (lib/channels/linha-do-espelho.ts): sem o
|
||
// escopo, com canal oficial e parceiro espelhando o mesmo nome, o agente
|
||
// renderizava e passava pelos gates o texto de OUTRO número.
|
||
const linha =
|
||
(await definicaoNaConexao<{
|
||
components: unknown;
|
||
parameter_format: string;
|
||
status: string;
|
||
}>(pool, ['components', 'parameter_format', 'status'], {
|
||
organizationId: tenantId,
|
||
name: template_name,
|
||
language,
|
||
channelSessionId: input.channelSessionId,
|
||
})) ?? undefined;
|
||
if (linha === undefined) {
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'template_desconhecido',
|
||
message:
|
||
`não existe template "${template_name}" em ${language} nesta conta. ` +
|
||
'Encerre o turno; um humano precisa configurá-lo.',
|
||
},
|
||
};
|
||
}
|
||
// "Existe" não é "pode ser disparado". A regra vive em template-binding.ts e
|
||
// o caminho HUMANO já a respeitava (recusa `not_approved` no menu do composer);
|
||
// este caminho não a consultava — e é o que age SEM humano olhando. Um template
|
||
// PENDING ou REJECTED iria à Graph API, voltaria erro genérico, e o modelo
|
||
// trataria como falha de infraestrutura em vez de configuração pendente.
|
||
//
|
||
// Erro SEPARADO de `template_desconhecido` de propósito: as duas causas pedem
|
||
// ações humanas diferentes — criar o template, ou esperar/consertar a análise
|
||
// da Meta. Colapsá-las manda o operador procurar no lugar errado.
|
||
if (!isStatusSendable(linha.status)) {
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'template_nao_aprovado',
|
||
message:
|
||
`o template "${template_name}" existe mas está ${linha.status} na Meta — ` +
|
||
'só um template APPROVED pode ser disparado. Encerre o turno; ' +
|
||
'um humano precisa resolver a aprovação.',
|
||
},
|
||
};
|
||
}
|
||
|
||
const rendered = renderTemplateBody(linha.components, values, {
|
||
name: template_name,
|
||
language,
|
||
parameterFormat: linha.parameter_format,
|
||
});
|
||
|
||
const chain = await runBeforeSend({
|
||
pool,
|
||
log: runLog,
|
||
agentOperation,
|
||
tenantId,
|
||
leadId,
|
||
jobId: liveJob().id,
|
||
channelSessionId: input.channelSessionId,
|
||
body: rendered,
|
||
// Só ESTE gate muda; stop, LGPD e pacing continuam valendo integralmente.
|
||
isTemplate: true,
|
||
optedOutThisTurn,
|
||
// Resposta do turno, mesmo sendo template: lê a janela de resposta (0495).
|
||
resposta: eTurnoDeResposta(liveJob()),
|
||
crmDailyLimit: null,
|
||
now: clock(),
|
||
sleep: deps.sleep,
|
||
lgpd,
|
||
send: (finalBody: string) => {
|
||
seq += 1;
|
||
return liveChannel().send({
|
||
tenantId,
|
||
leadId,
|
||
jobId: liveJob().id,
|
||
jobClaim: claimOfJob(liveJob()),
|
||
agentOperation,
|
||
seq,
|
||
conversationId: input.conversationId,
|
||
body: finalBody,
|
||
template: { name: template_name, language, values },
|
||
});
|
||
},
|
||
});
|
||
|
||
if (chain.status === 'vetoed') {
|
||
return { ok: false, error: { code: chain.code, message: chain.message } };
|
||
}
|
||
const outcome = chain.outcome;
|
||
outcomes.push(outcome);
|
||
if (outcome.kind === 'sent' || outcome.kind === 'already_sent') {
|
||
return {
|
||
ok: true,
|
||
status: 'enviada',
|
||
message_id: outcome.messageId,
|
||
// Explícito: sem isso o modelo tende a emendar texto livre depois do
|
||
// template — que a janela fechada recusaria.
|
||
message: 'template enviado. Não escreva mais nada neste turno.',
|
||
};
|
||
}
|
||
return { ok: true, status: 'aceita_aguardando_canal' };
|
||
},
|
||
}),
|
||
search_knowledge: tool({
|
||
...AGENT_TOOL_DEFS.search_knowledge,
|
||
execute: async ({ query }) => {
|
||
const fontes = agentConfig?.knowledgeSourceIds ?? [];
|
||
if (fontes.length === 0 && agentConfig?.activeKbVersionId == null) {
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'no_knowledge_base',
|
||
message: 'este agente não tem material de consulta habilitado — siga sem ele.',
|
||
},
|
||
};
|
||
}
|
||
const out = await searchKnowledge(
|
||
pool,
|
||
{
|
||
organizationId: tenantId,
|
||
knowledgeSourceIds: fontes,
|
||
kbVersionId: agentConfig?.activeKbVersionId ?? null,
|
||
query,
|
||
topK: agentConfig?.ragTopK ?? 5,
|
||
threshold: agentConfig?.ragSimilarityThreshold ?? 0.4,
|
||
jobId: job?.id,
|
||
agentId: agentConfig?.agentId ?? null,
|
||
},
|
||
{ log: runLog, embed: deps.embed },
|
||
);
|
||
if (out.ok && out.results.length > 0) {
|
||
// As citações são montadas AQUI, pelo código, a partir do resultado
|
||
// cru — é por isso que os ids podem sair do que vai ao modelo sem
|
||
// perder nada: quem precisa deles é esta linha, não o modelo.
|
||
pendingCitations = citationsFromHits(out.results);
|
||
}
|
||
// `chunk_id` e `knowledge_source_id` viajavam CRUS para o modelo em toda
|
||
// busca com RAG — dois UUIDs por resultado, sem uso nenhum do lado dele
|
||
// (nenhuma ferramenta os aceita como argumento). UUID cru na resposta ao
|
||
// cliente foi MEDIDO nesta base; esta era uma fonte silenciosa dele.
|
||
return turnoProjeta(mcpToolIdsDoTurno) ? projetarRetornoDeTool(out) : out;
|
||
},
|
||
}),
|
||
send_message: tool({
|
||
...AGENT_TOOL_DEFS.send_message,
|
||
execute: async ({ body, produto_codigo }) => {
|
||
// CORPO VAZIO NÃO SAI. Medido ao vivo (2026-09-19): o `gpt-4o-mini`
|
||
// chamou `send_message` várias vezes com corpo que virou vazio e o
|
||
// WhatsApp do cliente recebeu bolhas em branco. O schema garante
|
||
// min(1) no argumento, mas um `\n`/espaço passa e vira vazio depois do
|
||
// trim/gates. Recusar aqui devolve ao modelo para reescrever — nunca
|
||
// manda bolha em branco.
|
||
if (body.trim() === '') {
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'corpo_vazio',
|
||
message:
|
||
'O texto da mensagem ficou vazio. Escreva a resposta de verdade e chame send_message de novo.',
|
||
},
|
||
};
|
||
}
|
||
if (claimsCurrentInboundIsEmpty(body, mensagemDoJob)) {
|
||
falseEmptyInboundVetoCount += 1;
|
||
if (falseEmptyInboundVetoCount < MAX_VETOS_DE_FALSO_VAZIO) {
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'false_empty_inbound',
|
||
message:
|
||
'O cliente enviou texto nesta mensagem. Não diga que ela veio vazia, em branco ou sem texto. ' +
|
||
`Responda ao pedido real agora: ${JSON.stringify(mensagemDoJob)}. ` +
|
||
`Esta é a tentativa de correção ${falseEmptyInboundVetoCount}.`,
|
||
},
|
||
};
|
||
}
|
||
// Não há segunda cadeia a re-rodar aqui (diferente do vocabulário
|
||
// interno, que desarma um gate e chama `runBeforeSend` de novo): esta
|
||
// barreira é local ao `execute`, então soltar é seguir para o resto do
|
||
// caminho de envio, com a cadeia inteira ainda pela frente.
|
||
runLog.warn('fail-safe do gate de falso-vazio: envio liberado após vetos seguidos', {
|
||
vetos: falseEmptyInboundVetoCount,
|
||
});
|
||
}
|
||
if (seq >= maxSendsPerTurn) {
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'max_sends_per_turn',
|
||
message:
|
||
`você já enviou ${seq} mensagens neste turno (teto: ${maxSendsPerTurn}). ` +
|
||
'NÃO envie mais nada agora — encerre o turno e espere a resposta do lead.',
|
||
},
|
||
};
|
||
}
|
||
// RESPOSTA OBSOLETA: o cliente escreveu de novo enquanto este turno pensava.
|
||
// Só antes do PRIMEIRO envio — cortar a meio uma resposta já começada é pior
|
||
// que a duplicata. A mensagem nova tem job próprio, que lê a conversa inteira
|
||
// e responde a tudo de uma vez. Ver `respostaFicouObsoleta`.
|
||
if (
|
||
!preview &&
|
||
seq === 0 &&
|
||
(await respostaFicouObsoleta(
|
||
pool,
|
||
{ organizationId: tenantId, conversationId: input.conversationId, jobId: liveJob().id },
|
||
deps.knobs.respostaObsoletaTetoMs ?? 0,
|
||
))
|
||
) {
|
||
runLog.info('resposta descartada — o cliente escreveu de novo durante o turno', {
|
||
job_id: liveJob().id,
|
||
conversation_id: input.conversationId,
|
||
});
|
||
// #1943: marca o turno como descartado. NADA mais sai dele, nem a
|
||
// pergunta pendente do roteiro — ela segue feita para o turno da
|
||
// mensagem nova. Ver `perguntaDoRoteiroPodeSair` no `enviar`.
|
||
turnoDescartado = true;
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'resposta_obsoleta',
|
||
message:
|
||
'O cliente mandou mensagem nova enquanto você escrevia; esta resposta ficou desatualizada e NÃO foi enviada. ' +
|
||
'NÃO chame send_message de novo neste turno — encerre agora. O próximo turno lê a conversa inteira e responde a tudo de uma vez.',
|
||
},
|
||
};
|
||
}
|
||
// A foto do produto (ideia de @vgamkt, #1130): preparada ANTES da cadeia e
|
||
// fora do lock do número — a cópia no Storage é rede. Código errado volta
|
||
// ao modelo sem enviar nada; foto que não copiou sai do envio e o texto
|
||
// segue (degradar para só texto). Ver `agent/fotos-do-produto.ts`.
|
||
let fotosDoProduto: FotoParaEnvio[] = [];
|
||
let fotosQueFaltaram = 0;
|
||
if (produto_codigo !== undefined && produto_codigo.trim() !== '' && !preview) {
|
||
const preparadas = await prepararFotosDoProduto(pool, copiarFotoNoStorage(runLog), {
|
||
tenantId,
|
||
conversationId: input.conversationId,
|
||
codigo: produto_codigo,
|
||
});
|
||
if (!preparadas.ok) {
|
||
return { ok: false, error: { code: preparadas.code, message: preparadas.message } };
|
||
}
|
||
fotosDoProduto = preparadas.fotos;
|
||
fotosQueFaltaram = preparadas.tinha - preparadas.fotos.length;
|
||
}
|
||
// F4-04: sinaliza (independente do gate F4-01/F4-08) se ESTA candidata é uma
|
||
// promessa fora de tabela — usado só para correlacionar com o jailbreak no fim do
|
||
// turno. A detecção é determinística (decidePromise); sem tabela do tenant = no-op.
|
||
if (
|
||
promiseTable !== null &&
|
||
!decidePromise({ candidate: body, table: promiseTable }).allow
|
||
) {
|
||
outOfTablePromiseAttempted = true;
|
||
}
|
||
// Cadeia de guardrails (F2-13): stop/opt-out → anti-ban → spinning rodam
|
||
// AQUI, entre a decisão do modelo e o adapter. Se um gate veta, o
|
||
// channel.send NÃO acontece e a razão volta ao modelo como erro instrutivo;
|
||
// seq só avança quando o envio é de fato tentado (gate veto não gasta seq
|
||
// — preserva o alinhamento (job_id, seq) do ledger F2-06 entre re-runs).
|
||
try {
|
||
// Wave 4 (spec 15 §10.2): estado de caso lido FRESCO a cada tentativa de envio
|
||
// (pode ter mudado dentro deste MESMO turno via open_human_case, chamado antes
|
||
// deste send_message). casesEnabled false (tela não habilita) → sempre false,
|
||
// sem query — o casePromiseGate já é no-op nesse caso de qualquer forma.
|
||
const hasOpenCase =
|
||
agentConfig?.casesEnabled === true
|
||
? await hasOpenCaseForContact(pool, tenantId, input.conversationId)
|
||
: false;
|
||
// Args reusados EXATAMENTE (mesmo objeto) no re-run do fail-safe abaixo — só
|
||
// hasOpenCase/openedCaseThisTurn mudam depois do auto-abre-caso.
|
||
const beforeSendArgs = {
|
||
pool,
|
||
log: runLog,
|
||
agentOperation,
|
||
tenantId,
|
||
leadId,
|
||
jobId: liveJob().id,
|
||
channelSessionId: input.channelSessionId,
|
||
body,
|
||
optedOutThisTurn,
|
||
// `inbound_turn`/`case_reply_turn` respondem a quem escreveu e leem a
|
||
// janela de RESPOSTA (0495). `followup_turn` retoma conversa parada e
|
||
// continua na janela de DISPARO.
|
||
resposta: eTurnoDeResposta(liveJob()),
|
||
// ponytail: channel_sessions.daily_message_limit do CRM ainda não é lido
|
||
// no runtime — null cai nos degraus de warm-up (conservadores). Injetar
|
||
// aqui quando o drain expuser o limite da sessão.
|
||
crmDailyLimit: null,
|
||
now: clock(),
|
||
sleep: deps.sleep,
|
||
lgpd,
|
||
casesEnabled: agentConfig?.casesEnabled ?? false,
|
||
hasOpenCase,
|
||
openedCaseThisTurn,
|
||
// Nome(s) próprio(s) que o prompt do tenant usa pra retaguarda humana (ex.:
|
||
// "Fulano") — o mesmo vocabulário que `matchesHandoffKeyword` já usa do lado
|
||
// do CLIENTE, agora somado ao alvo genérico do `casePromiseGate` do lado do
|
||
// que o MODELO promete. Ver `GateContext.humanPromiseExtraTargets`.
|
||
humanPromiseExtraTargets: agentConfig?.handoffKeywords ?? [],
|
||
// A rede contra vazamento de vocabulário interno arma AQUI e só aqui: este é
|
||
// o único corpo escrito pelo MODELO, e o único caminho em que o veto vira
|
||
// erro instrutivo que ele pode consertar no turno seguinte. O `send_template`
|
||
// (mais acima) fica desarmado de propósito — o texto lá é do humano e já
|
||
// aprovado pela Meta; vetá-lo devolveria ao modelo a culpa por uma frase que
|
||
// não é dele, e a única saída seria o silêncio. O follow-up determinístico
|
||
// idem (ver GateContext.internalVocabularyEnforced).
|
||
enforceInternalVocabulary: true,
|
||
// Mesmo padrão do vocabulário interno: só o `send_message` arma — é o único
|
||
// corpo escrito pelo modelo. `active` é ter QUALQUER ferramenta de agenda:
|
||
// um agente que só CONSULTA promete "vou verificar" igual, e enquanto a
|
||
// condição era só `crm_book_appointment` ele ficava sem o gate. Quem não tem
|
||
// ferramenta de agenda nenhuma segue desarmado — vetá-lo não teria cura.
|
||
agenda: {
|
||
active: agentConfig !== null && temFerramentaDeAgenda(agentConfig.toolIds),
|
||
ferramentas: agentConfig === null ? [] : ferramentasDeAgendaDoAgente(agentConfig.toolIds),
|
||
toolCalledThisTurn: agendaToolCalledThisTurn,
|
||
},
|
||
...(deps.knobs.disclosureMode !== undefined
|
||
? { disclosureMode: deps.knobs.disclosureMode }
|
||
: {}),
|
||
// Gate 5 (F4-02): classificador semântico roteado pelo MESMO seam agnóstico (budget
|
||
// da org checado nele). Closure com tenant/lead/job da ROW fechados — nunca do payload.
|
||
...(semanticClassifier !== undefined
|
||
? { classifyPromiseSemantic: semanticClassifier }
|
||
: {}),
|
||
// Pausa humana do turno, paga FORA do lock do número (issue #654). Antes ela
|
||
// era paga dentro do `send` logo abaixo (via `antesDaPrimeira`), e o `send`
|
||
// só acontece com o `pg_advisory_xact_lock` do canal na mão — cada turno
|
||
// segurava a fila do NÚMERO por 1,2s–7,5s além do necessário. Agora o
|
||
// guardrail a paga antes de tomar conexão: sem transação aberta durante a espera.
|
||
//
|
||
// O texto que dimensiona a pausa é a 1ª bolha do MESMO fatiamento que o
|
||
// `sendInBubbles` usa (`splitForSend` é a fonte única da decisão) — a pausa
|
||
// segue proporcional ao que o cliente lê primeiro, não ao corpo todo.
|
||
//
|
||
// Diferença declarada: aqui o texto é o `body` PRÉ-cadeia; o `finalBody`
|
||
// pós-disclosure só existe do lado de dentro do guardrail. Um disclosure
|
||
// prependado pelo gate F4-05 não entra na conta da espera (antes entrava,
|
||
// porque o gancho recebia `finalBody`).
|
||
esperaForaDoLock: async (): Promise<void> => {
|
||
// Uma vez por TURNO — o flag impede que um re-run do fail-safe (veto de
|
||
// promessa/vocabulário) cobre a espera de novo do mesmo cliente.
|
||
if (jaEsperouComoHumano) return;
|
||
jaEsperouComoHumano = true;
|
||
// `liveChannel()`, não `channel`: o transporte é anulável (preview não tem
|
||
// canal) e este é o MESMO acessor que o `send` logo abaixo usa. Resolver
|
||
// antes da espera mantém o desfecho de preview idêntico ao de antes —
|
||
// `preview_transport_forbidden` na hora, e não depois da pausa.
|
||
const canal = liveChannel();
|
||
const ms = await esperarComoHumano({
|
||
texto:
|
||
splitForSend(
|
||
body,
|
||
agentConfig?.splitMessages ?? false,
|
||
agentConfig?.splitMaxChars ?? 600,
|
||
Math.max(1, maxSendsPerTurn - seq),
|
||
)[0] ?? body,
|
||
// Os quatro números do atraso por conexão (0499). Vazios (sem
|
||
// linha em channel_knobs / preview) = defaults históricos.
|
||
knobs: knobsDeAtrasoHumano,
|
||
// `processamentoMs` é a contribuição do #849 (@Teowfb): a pausa humana desconta o
|
||
// tempo que o turno JÁ gastou pensando, em vez de somar em cima dele. Sem este
|
||
// argumento o `gasto` de `atraso-humano.ts` cai no `?? 0` e o desconto não acontece —
|
||
// o cliente espera duas vezes. O ponto de chamada mudou de lugar com a #654 (a pausa
|
||
// saiu de `antesDaPrimeira`, dentro do lock, para cá), e o desconto veio junto.
|
||
processamentoMs: performance.now() - inicioDoProcessamento,
|
||
sleep: deps.sleep ?? ((s) => new Promise((resolve) => setTimeout(resolve, s))),
|
||
log: runLog,
|
||
...(canal.signalTyping
|
||
? {
|
||
sinalizarDigitando: (): Promise<void> =>
|
||
canal.signalTyping!({ tenantId, conversationId: input.conversationId }),
|
||
}
|
||
: {}),
|
||
});
|
||
runLog.info('atraso humano antes da 1ª bolha', { atraso_ms: ms, fora_do_lock: true });
|
||
},
|
||
// `finalBody` = corpo após a cadeia (o disclosureGate F4-05 pode prependar o
|
||
// disclosure via inject); é ELE que vai ao canal, não o `body` capturado da tool.
|
||
send: (finalBody: string) => {
|
||
corposEnviados.push(finalBody);
|
||
const sleep =
|
||
deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
||
const jitter = () =>
|
||
// Piso no throttle anti-ban do número (1.2s default) — bolhas são
|
||
// mensagens físicas. Lê os knobs da CONEXÃO (throttle_ms + jitter_max_ms,
|
||
// 0499) e não o literal: desde a 0010 os dois já são configuração por
|
||
// número, e o call site estava cravando `1200 + rand*800` ignorando-a.
|
||
(pacingDoTurno?.knobs.throttleMs ?? 1200) +
|
||
Math.floor(Math.random() * (pacingDoTurno?.knobs.jitterMaxMs ?? 800));
|
||
const enviar = (
|
||
corpo: string,
|
||
media?: FotoParaEnvio,
|
||
): Promise<ChannelSendResult> => {
|
||
seq += 1;
|
||
return liveChannel().send({
|
||
tenantId,
|
||
leadId,
|
||
jobId: liveJob().id,
|
||
jobClaim: claimOfJob(liveJob()),
|
||
agentOperation,
|
||
seq,
|
||
conversationId: input.conversationId,
|
||
body: corpo,
|
||
...(media ? { media } : {}),
|
||
});
|
||
};
|
||
// Cada foto é uma mensagem física: só vão as que cabem no que resta do teto
|
||
// do turno (a checagem de `max_sends_per_turn` acima roda uma vez, antes).
|
||
// O resto é medido ANTES DE CADA FOTO, depois do texto: o texto acima do
|
||
// teto de legenda sai à parte e também gasta o teto.
|
||
return enviarComFotos(finalBody, fotosDoProduto, {
|
||
sleep,
|
||
jitter,
|
||
restantes: () => maxSendsPerTurn - seq,
|
||
enviarFoto: (foto, legenda) => enviar(legenda, foto),
|
||
enviarTexto: (texto) =>
|
||
sendInBubbles(texto, {
|
||
enabled: agentConfig?.splitMessages ?? false,
|
||
maxChars: agentConfig?.splitMaxChars ?? 600,
|
||
// O teto do turno vale para as bolhas: o que passa dele vai junto na última.
|
||
maxBubbles: Math.max(1, maxSendsPerTurn - seq),
|
||
sleep,
|
||
jitter,
|
||
// A pausa humana do turno NÃO mora mais aqui: ela subiu para
|
||
// `esperaForaDoLock` (paga antes de o guardrail tomar o lock do número) —
|
||
// issue #654. Neste ponto fica só o jitter anti-ban entre bolhas.
|
||
send: (bubble) => enviar(bubble),
|
||
}),
|
||
});
|
||
},
|
||
};
|
||
let chain = await runBeforeSend(beforeSendArgs);
|
||
if (chain.status === 'vetoed' && chain.code === 'case_promise_without_case') {
|
||
// Wave 4 — fail-safe da invariante sagrada: o lead NUNCA recebe promessa-de-
|
||
// humano sem caso aberto. 1ª vez no turno: erro-de-ensino (o modelo re-tenta —
|
||
// abre o caso OU reformula sem prometer humano). Persistiu (2ª vez): o SISTEMA
|
||
// abre um caso mínimo e libera o envio — nunca deixa a promessa passar sem caso.
|
||
casePromiseVetoCount += 1;
|
||
if (casePromiseVetoCount < 2) {
|
||
return { ok: false, error: { code: chain.code, message: chain.message } };
|
||
}
|
||
const auto = await openCase(
|
||
pool,
|
||
{
|
||
tenantId,
|
||
conversationId: input.conversationId,
|
||
agentId: agentConfig?.agentId ?? null,
|
||
},
|
||
{
|
||
title: 'Atendimento que precisa de um humano',
|
||
summary: body, // a mensagem-promessa que a IA tentou enviar
|
||
blocker:
|
||
'Aberto automaticamente: a IA prometeu envolver um humano e não abriu o caso (fail-safe do guardrail).',
|
||
source: 'guardrail_autofallback',
|
||
contextSnapshot: buildCaseContextSnapshot(),
|
||
},
|
||
);
|
||
if (!auto.ok) {
|
||
// openCase falhou (ex.: já existe outro caso aberto por corrida) — NÃO envie
|
||
// prometendo humano sem caso; mantém a invariante com o erro de ensino original.
|
||
return { ok: false, error: { code: chain.code, message: chain.message } };
|
||
}
|
||
openedCaseThisTurn = true;
|
||
moverParaHandoffBestEffort('case_promise_autofallback');
|
||
// Re-roda a cadeia INTEIRA agora que há caso aberto — o send real acontece
|
||
// DENTRO do runBeforeSend (via args.send); nunca chamamos o canal por fora
|
||
// (perderia pacing/lgpd/stop). ponytail: re-roda a cadeia inteira no fail-safe
|
||
// (raro) — pode reaplicar 1 espera de pacing; aceitável pelo caminho ser
|
||
// excepcional.
|
||
chain = await runBeforeSend({
|
||
...beforeSendArgs,
|
||
hasOpenCase: true,
|
||
openedCaseThisTurn: true,
|
||
});
|
||
}
|
||
if (chain.status === 'vetoed' && chain.code === 'internal_vocabulary_leak') {
|
||
// Fail-safe do gate de vazamento — O CLIENTE NUNCA FICA SEM RESPOSTA.
|
||
//
|
||
// Este gate é REDE, não invariante sagrada (ao contrário do case_promise, cuja
|
||
// 2ª camada ABRE o caso antes de liberar). Aqui não há o que o sistema possa
|
||
// fazer no lugar do modelo: ou ele reescreve, ou a escolha é entre uma frase
|
||
// com um termo técnico e o silêncio. Silêncio é pior — some com o atendimento
|
||
// sem sintoma, que é o oposto do invariante 4 do sistema vivo. Então: 1º veto
|
||
// ensina (o modelo re-tenta); persistiu, o envio sai DESARMANDO só este gate —
|
||
// todos os outros continuam valendo, porque o re-run passa pela cadeia inteira.
|
||
//
|
||
// O veto da 1ª tentativa já virou linha em `before_send_traces` (com a
|
||
// categoria do vazamento) e atividade na timeline: a liberação não apaga a
|
||
// medição, que é o produto deste gate.
|
||
internalVocabularyVetoCount += 1;
|
||
if (internalVocabularyVetoCount < MAX_VETOS_DE_VOCABULARIO_INTERNO) {
|
||
return { ok: false, error: { code: chain.code, message: chain.message } };
|
||
}
|
||
runLog.warn(
|
||
'fail-safe do gate de vocabulário interno: envio liberado após vetos seguidos',
|
||
{
|
||
vetos: internalVocabularyVetoCount,
|
||
},
|
||
);
|
||
// `openedCaseThisTurn` vai pelo valor VIVO (o fail-safe de casos acima pode
|
||
// tê-lo mudado); reusar o do objeto capturado re-vetaria no case_promise.
|
||
chain = await runBeforeSend({
|
||
...beforeSendArgs,
|
||
openedCaseThisTurn,
|
||
hasOpenCase: hasOpenCase || openedCaseThisTurn,
|
||
enforceInternalVocabulary: false,
|
||
});
|
||
}
|
||
if (chain.status === 'vetoed') {
|
||
// Cap de warm-up/diário: reescrever o texto não resolve (é rate limit, não
|
||
// conteúdo) — ensinar o modelo a "tentar de novo" só gasta passo. Guardamos
|
||
// pra reagendar o JOB inteiro depois que o turno terminar (mesmo padrão de
|
||
// `rescheduleJob` já usado pra janela horária), em vez de deixar o lead sem
|
||
// resposta até a próxima mensagem dele chegar (ou nunca).
|
||
if (
|
||
(chain.code === 'warmup_cap' || chain.code === 'daily_cap') &&
|
||
chain.nextAllowedAt !== undefined
|
||
) {
|
||
pacingCapVeto = { code: chain.code, nextAllowedAt: chain.nextAllowedAt };
|
||
}
|
||
// Erro de ENSINO pt-br (mesmo shape de get_lead_context/breaker): o
|
||
// modelo o vê no turno seguinte. NÃO é exceção — não derruba o run.
|
||
return { ok: false, error: { code: chain.code, message: chain.message } };
|
||
}
|
||
const outcome = chain.outcome;
|
||
outcomes.push(outcome);
|
||
if (outcome.kind === 'sent' && pendingCitations.length > 0) {
|
||
try {
|
||
await pool.query(
|
||
`update messages
|
||
set metadata = coalesce(metadata, '{}'::jsonb)
|
||
|| jsonb_build_object('citations', $3::jsonb, 'ai_generated', true)
|
||
where organization_id = $1 and id = $2`,
|
||
[tenantId, outcome.messageId, JSON.stringify(pendingCitations)],
|
||
);
|
||
} catch (err) {
|
||
// citação é enriquecimento, não invariante — falha só loga.
|
||
runLog.warn('citações não anexadas à outbound', {
|
||
message_id: outcome.messageId,
|
||
error: (err instanceof Error ? err.message : String(err)).slice(0, 120),
|
||
});
|
||
}
|
||
pendingCitations = [];
|
||
}
|
||
switch (outcome.kind) {
|
||
case 'sent':
|
||
case 'already_sent':
|
||
return {
|
||
ok: true,
|
||
status: 'enviada',
|
||
message_id: outcome.messageId,
|
||
...(produto_codigo !== undefined ? { fotos_enviadas: fotosDoProduto.length } : {}),
|
||
...(fotosQueFaltaram > 0
|
||
? {
|
||
aviso: `${fotosQueFaltaram} foto(s) do produto não puderam ser enviadas; o texto foi. Não diga ao cliente que mandou essas fotos.`,
|
||
}
|
||
: {}),
|
||
};
|
||
case 'queued':
|
||
return {
|
||
ok: true,
|
||
status: 'aceita_aguardando_canal',
|
||
message:
|
||
'o canal aceitou a mensagem e vai enviá-la quando a sessão voltar — não reenvie.',
|
||
};
|
||
case 'blocked':
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'contato_bloqueado',
|
||
message:
|
||
'o contato optou por não receber mensagens (bloqueio irrevogável) — não envie mais nada e encerre o turno.',
|
||
},
|
||
};
|
||
case 'failed':
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'envio_falhou',
|
||
message:
|
||
'o canal falhou ao enviar — não tente de novo neste turno; o sistema fará retry.',
|
||
},
|
||
};
|
||
case 'unavailable':
|
||
// transiente (transporte/tool do canal): ensina o modelo a parar; o
|
||
// job re-tenta com a MESMA idempotency_key (ledger ficou 'requested').
|
||
noteRunError(
|
||
new Error(
|
||
`canal indisponível no envio (${outcome.reason}) — job re-tentado pela fila`,
|
||
),
|
||
);
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'envio_indisponivel',
|
||
message:
|
||
'não consegui enviar agora (canal indisponível) — encerre o turno; o sistema re-tentará.',
|
||
},
|
||
};
|
||
}
|
||
} catch (err) {
|
||
// bug de programação no adapter: ensina o modelo a encerrar E derruba o job.
|
||
noteRunError(err instanceof Error ? err : new Error(String(err)));
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'internal_error',
|
||
message: 'erro interno no envio — encerre o turno agora.',
|
||
},
|
||
};
|
||
}
|
||
},
|
||
}),
|
||
update_lead_state: tool({
|
||
...AGENT_TOOL_DEFS.update_lead_state,
|
||
execute: async (raw) => {
|
||
try {
|
||
const update = await applyLeadStateUpdate(
|
||
pool,
|
||
{ tenantId, leadId, jobId: liveJob().id },
|
||
raw,
|
||
);
|
||
if (!update.ok) {
|
||
return update; // erro de ensino (payload fora da whitelist / transição inválida)
|
||
}
|
||
if (update.transition !== null) {
|
||
// Espelho no CRM. Falha NUNCA reverte o harness (fonte da verdade do
|
||
// funil) nem falha o job: humano resolve via inbox_items. Os motivos
|
||
// de MIRROR_WARN_ONLY (tenant sem mapa; humano moveu o card antes) são
|
||
// só warn — estado legítimo do produto não é incidente. Os outros dois
|
||
// merecem aviso PRÓPRIO, cada um no seu: `fora_do_escopo` (nada quebrou,
|
||
// o dono decide se libera o funil) e `perda_sem_motivo` (#917 — o card
|
||
// não anda porque a perda exige um motivo que só o humano pode dar).
|
||
const mirror = await mirrorLeadStageToCrm(pool, deps.crmCfg, {
|
||
tenantId,
|
||
leadId,
|
||
toStage: update.transition.to,
|
||
...(agentConfig !== null ? { pipelineIds: agentConfig.pipelineIds } : {}),
|
||
...(update.transition.reason !== undefined
|
||
? { reason: update.transition.reason }
|
||
: {}),
|
||
});
|
||
if (!mirror.ok) {
|
||
runLog.warn('espelho de stage no CRM falhou — harness mantido', {
|
||
to_stage: update.transition.to,
|
||
reason: mirror.reason,
|
||
});
|
||
// QUAL aviso cada recusa produz, e como ele deixa de se repetir, é
|
||
// decisão de `move-lead-stage` — aqui só se passa o motivo e o
|
||
// lead. Ver `abreAvisoDoEspelhoRecusado`: escrever o
|
||
// `insertInboxItem` à mão neste ponto é o que deixava o `dedupe`
|
||
// sem guarda.
|
||
await abreAvisoDoEspelhoRecusado(pool, tenantId, {
|
||
leadId,
|
||
motivo: mirror.reason,
|
||
detalhe: mirror.detail,
|
||
etapaDeDestino: update.transition.to,
|
||
});
|
||
}
|
||
}
|
||
// F3-11: o estágio que o modelo confirmou (a máquina F2-10 gravou) — base da
|
||
// comparação com a sugestão do classificador no fechamento do run.
|
||
confirmedStage = update.state.stage;
|
||
return {
|
||
ok: true,
|
||
status: 'estado_atualizado',
|
||
stage: update.state.stage,
|
||
message: update.message,
|
||
};
|
||
} catch (err) {
|
||
noteRunError(err instanceof Error ? err : new Error(String(err)));
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'internal_error',
|
||
message: 'erro interno ao atualizar o estado do lead — encerre o turno agora.',
|
||
},
|
||
};
|
||
}
|
||
},
|
||
}),
|
||
// F3-05: memória durável por lead. save_lead_note é MUTANTE (fora de
|
||
// READ_ONLY_TOOLS); tenant/lead vêm da ROW do job (closure), nunca do payload.
|
||
// Hard cap do índice imposto AQUI na escrita (applySaveLeadNote) — estouro vira
|
||
// ensino pedindo consolidação, sem gravar (padrão Hermes).
|
||
save_lead_note: tool({
|
||
...AGENT_TOOL_DEFS.save_lead_note,
|
||
execute: async (raw) => {
|
||
try {
|
||
const res = await applySaveLeadNote(
|
||
pool,
|
||
{ tenantId, leadId },
|
||
{ budgetTokens: deps.knobs.notesIndexMaxTokens },
|
||
raw,
|
||
);
|
||
if (!res.ok) {
|
||
return res; // ensino (payload fora da whitelist / orçamento do índice estourado)
|
||
}
|
||
return {
|
||
ok: true,
|
||
status: 'nota_salva',
|
||
superseded: res.superseded,
|
||
message: res.message,
|
||
};
|
||
} catch (err) {
|
||
noteRunError(err instanceof Error ? err : new Error(String(err)));
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'internal_error',
|
||
message: 'erro interno ao salvar a nota — encerre o turno agora.',
|
||
},
|
||
};
|
||
}
|
||
},
|
||
}),
|
||
// get_lead_note é READ-ONLY: relê o corpo de UMA nota do lead pelo id (sob demanda —
|
||
// o índice só traz headline). Escopado por (tenant, lead) do closure.
|
||
get_lead_note: tool({
|
||
...AGENT_TOOL_DEFS.get_lead_note,
|
||
execute: async ({ note_id }) => {
|
||
try {
|
||
const noteId = note_id.trim();
|
||
const body = noteId === '' ? null : await getLeadNoteBody(pool, tenantId, leadId, noteId);
|
||
if (body === null) {
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'note_not_found',
|
||
message:
|
||
'não há nota com esse id na memória deste lead — confira o id no índice de memória.',
|
||
},
|
||
};
|
||
}
|
||
return { ok: true, note_id: noteId, body };
|
||
} catch (err) {
|
||
noteRunError(err instanceof Error ? err : new Error(String(err)));
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'internal_error',
|
||
message: 'erro interno ao ler a nota — encerre o turno agora.',
|
||
},
|
||
};
|
||
}
|
||
},
|
||
}),
|
||
// F4-06: handoff humano acionado pelo PRÓPRIO modelo (cidadão de 1ª classe). MUTANTE
|
||
// (seta force_human no CRM + cancela crons + inbox), fora de READ_ONLY_TOOLS. tenant/
|
||
// lead/conversation vêm da ROW do job (closure), nunca do payload do modelo.
|
||
request_human_handoff: tool({
|
||
...AGENT_TOOL_DEFS.request_human_handoff,
|
||
execute: async (raw) => {
|
||
try {
|
||
// ═══ O PISO: se o modelo não falou, o sistema fala ═══
|
||
//
|
||
// A descrição da tool manda avisar o lead ANTES de chamá-la, e a
|
||
// mensagem de retorno repete. Mas capacidade que depende de o modelo
|
||
// LEMBRAR é capacidade que não existe metade das vezes — a mesma
|
||
// conclusão que fez `expectativaDeAtendimento` parar de esperar que
|
||
// ele consultasse a disponibilidade sozinho.
|
||
//
|
||
// `seq` é o contador de mensagens FÍSICAS já enviadas neste turno. Zero
|
||
// significa: o modelo decidiu passar a conversa sem dizer nada a
|
||
// ninguém — e depois desta tool ele não consegue mais falar, porque
|
||
// `force_human` arma o `stopGate`. Então o aviso determinístico sai
|
||
// AGORA, antes do handoff.
|
||
//
|
||
// `seq > 0` significa que ele JÁ falou neste turno; mandar o aviso ali
|
||
// em cima seria o robô dizendo duas vezes a mesma coisa, com palavras
|
||
// diferentes. Confiamos na fala dele e registramos que o piso não foi
|
||
// preciso.
|
||
const aviso =
|
||
seq === 0
|
||
? await avisarLeadDaEscalacao(pool, avisoDaEscalacao().ids, {
|
||
...avisoDaEscalacao().base,
|
||
motivo: 'pediu_humano',
|
||
})
|
||
: ({ avisado: true } as const);
|
||
// O contexto do TURNO vai junto, e sai da closure: `previous` é o
|
||
// checkpoint durável e `inboundsPendentes` é o que o cliente disse e
|
||
// ainda não foi respondido — os dois já estão em memória, então o
|
||
// briefing enriquecido não custa uma consulta a mais.
|
||
const res = await applyRequestHumanHandoff(
|
||
pool,
|
||
{ tenantId, leadId, conversationId: input.conversationId },
|
||
{
|
||
conversationSummary: buildHandoffSummary(previous),
|
||
contextoDoTurno: { checkpoint: previous, pendentesDoCliente: inboundsPendentes },
|
||
avisoAoLead: aviso,
|
||
log: runLog,
|
||
},
|
||
raw,
|
||
);
|
||
if (!res.ok) return res; // erro de ensino (payload fora da whitelist)
|
||
return { ok: true, status: res.status, message: res.message };
|
||
} catch (err) {
|
||
noteRunError(err instanceof Error ? err : new Error(String(err)));
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'internal_error',
|
||
message: 'erro interno ao acionar o handoff humano — encerre o turno agora.',
|
||
},
|
||
};
|
||
}
|
||
},
|
||
}),
|
||
};
|
||
|
||
// F3-02: a tool nativa só entra com janela configurada e callback habilitado
|
||
// na versão publicada. Tenant/lead vêm da ROW do job (closure), nunca do
|
||
// payload do modelo. É MUTANTE (cria cron_job), por isso fica fora de
|
||
// READ_ONLY_TOOLS.
|
||
const followupKnobs = deps.knobs.followup;
|
||
if (podeExporScheduleFollowup(agentConfig?.followup, followupKnobs)) {
|
||
rawTools.schedule_followup = tool({
|
||
...AGENT_TOOL_DEFS.schedule_followup,
|
||
execute: async (raw) => {
|
||
try {
|
||
// agentId vai junto para a atividade da timeline nascer com AUTORIA: sem
|
||
// ele a linha entra como "Sistema" e o humano não sabe qual agente
|
||
// prometeu voltar — numa org com três agentes isso não responde nada.
|
||
const res = await applyScheduleFollowup(
|
||
pool,
|
||
{ clock, knobs: followupKnobs },
|
||
{ tenantId, leadId, agentId: agentConfig?.agentId ?? null },
|
||
raw,
|
||
);
|
||
if (!res.ok) {
|
||
return res; // erro de ensino (payload / data no passado / fora da janela)
|
||
}
|
||
return {
|
||
ok: true,
|
||
status: 'agendado',
|
||
agendado_para: res.promisedAt.toISOString(),
|
||
message: res.message,
|
||
};
|
||
} catch (err) {
|
||
noteRunError(err instanceof Error ? err : new Error(String(err)));
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'internal_error',
|
||
message: 'erro interno ao agendar o retorno — encerre o turno agora.',
|
||
},
|
||
};
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
// Fase 2 (Task 6): read_skill_reference só entra quando alguma skill CASADA neste
|
||
// turno carrega references no manifesto (Task 3) — sem isso oferecer a tool seria
|
||
// ruído. Read-only (tool-breaker.ts); tenant/matched skills vêm do closure
|
||
// (skillMatch, calculado acima), nunca do payload do modelo.
|
||
if (skillMatch.matched.some((s) => skillHasReferences(s))) {
|
||
rawTools.read_skill_reference = tool({
|
||
...AGENT_TOOL_DEFS.read_skill_reference,
|
||
execute: async ({ skill_name, ref_path }) => {
|
||
try {
|
||
return await readSkillReference(
|
||
{ admin: deps.crmCfg.supabase },
|
||
{
|
||
organizationId: tenantId,
|
||
matchedSkills: skillMatch.matched,
|
||
skillName: skill_name,
|
||
refPath: ref_path,
|
||
},
|
||
);
|
||
} catch (err) {
|
||
noteRunError(err instanceof Error ? err : new Error(String(err)));
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'internal_error',
|
||
message: 'erro interno ao ler a reference da skill — encerre o turno agora.',
|
||
},
|
||
};
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
// Fase 2B: a tela pode DESLIGAR a tool de handoff do modelo (a detecção
|
||
// determinística de pedido de humano continua ativa — guardrail nunca sai).
|
||
if (agentConfig !== null && !agentConfig.handoffToolEnabled) {
|
||
delete rawTools.request_human_handoff;
|
||
}
|
||
|
||
// Spec 15: snapshot mínimo do contexto disponível pro humano que for atender o
|
||
// caso — campo de CONVENIÊNCIA pra UI, não load-bearing (nada aqui é relido pelo
|
||
// agente). ponytail: snapshot mínimo; enriquecer se a UI precisar de mais.
|
||
const buildCaseContextSnapshot = (): Record<string, unknown> => ({
|
||
contact_name: effectiveContext.contact.name,
|
||
last_messages: effectiveContext.messages
|
||
.slice(-5)
|
||
.map((m) => ({ direction: m.direction, body: m.body })),
|
||
});
|
||
|
||
// Spec 15 (Wave 3a): tools de caso humano (open_human_case/provide_case_update) só
|
||
// entram quando a tela habilita (cases_enabled) — mesmo padrão do handoff acima.
|
||
// Ids do closure (row do job), nunca do payload; payload inválido é erro de ENSINO
|
||
// ({ok:false}), exceção real vira internal_error (mesma disciplina dos irmãos).
|
||
if (agentConfig !== null && agentConfig.casesEnabled) {
|
||
rawTools.open_human_case = tool({
|
||
...AGENT_TOOL_DEFS.open_human_case,
|
||
execute: async (raw) => {
|
||
const parsed = openHumanCaseInputSchema.safeParse(raw);
|
||
if (!parsed.success) {
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'invalid_payload',
|
||
message: 'campos do caso inválidos — informe title, summary e blocker (texto).',
|
||
},
|
||
};
|
||
}
|
||
try {
|
||
const res = await openCase(
|
||
pool,
|
||
{ tenantId, conversationId: input.conversationId, agentId: agentConfig.agentId },
|
||
{ ...parsed.data, contextSnapshot: buildCaseContextSnapshot() },
|
||
);
|
||
if (!res.ok) return res;
|
||
openedCaseThisTurn = true;
|
||
moverParaHandoffBestEffort('open_human_case');
|
||
// ACH-03: a expectativa vai junto com a confirmação. Medido num turno
|
||
// real: o agente abria o caso e prometia ao cliente que "alguém entra
|
||
// em contato" sem nunca ter olhado se havia alguém — a capacidade de
|
||
// consultar existia, estava ligada e montada no turno, e ele não a
|
||
// usou. Capacidade que depende de o modelo lembrar não existe metade
|
||
// das vezes; esta o sistema garante.
|
||
const { frase } = await expectativaDeAtendimento(pool, tenantId, new Date());
|
||
return {
|
||
ok: true,
|
||
case_id: res.caseId,
|
||
message: `caso aberto; continue a conversa com o lead normalmente. ${frase}`,
|
||
};
|
||
} catch (err) {
|
||
noteRunError(err instanceof Error ? err : new Error(String(err)));
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'internal_error',
|
||
message: 'erro interno ao abrir o caso — encerre o turno.',
|
||
},
|
||
};
|
||
}
|
||
},
|
||
});
|
||
rawTools.provide_case_update = tool({
|
||
...AGENT_TOOL_DEFS.provide_case_update,
|
||
execute: async (raw) => {
|
||
const parsed = provideCaseUpdateInputSchema.safeParse(raw);
|
||
if (!parsed.success) {
|
||
return {
|
||
ok: false,
|
||
error: { code: 'invalid_payload', message: 'informe case_id e info (texto).' },
|
||
};
|
||
}
|
||
try {
|
||
const res = await provideCaseUpdate(
|
||
pool,
|
||
{ tenantId, conversationId: input.conversationId },
|
||
{ caseId: parsed.data.case_id, info: parsed.data.info },
|
||
);
|
||
if (!res.ok) return res;
|
||
return {
|
||
ok: true,
|
||
message: 'informação enviada ao responsável; aguarde o retorno pelo caso.',
|
||
};
|
||
} catch (err) {
|
||
noteRunError(err instanceof Error ? err : new Error(String(err)));
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
code: 'internal_error',
|
||
message: 'erro interno ao atualizar o caso — encerre o turno.',
|
||
},
|
||
};
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
// A tool de conhecimento só entra quando o agente publicado tem material para
|
||
// consultar. Desde a 0181 isso é a lista de materiais escolhida na tela; o
|
||
// ponteiro legado (`activeKbVersionId`) segue valendo para o clone que ainda
|
||
// não aplicou a migration. Ferramenta que só sabe responder "não tenho base"
|
||
// não é neutra: gasta contexto e degrada a escolha do modelo.
|
||
if (
|
||
(agentConfig?.knowledgeSourceIds?.length ?? 0) === 0 &&
|
||
agentConfig?.activeKbVersionId == null
|
||
) {
|
||
delete rawTools.search_knowledge;
|
||
}
|
||
|
||
// A ferramenta de template só entra em canal que EXIGE template fora da janela.
|
||
// Num canal que fala livre a qualquer hora ela nunca teria uso — e tool inútil no
|
||
// prompt não é neutra: gasta contexto e degrada a escolha do modelo.
|
||
{
|
||
const provider =
|
||
preview && !preview.channelId
|
||
? DEFAULT_CHANNEL_PROVIDER
|
||
: await loadChannelProvider(pool, tenantId, input.channelSessionId);
|
||
if (!capabilitiesOf(provider).requiresTemplates) {
|
||
delete rawTools.send_template;
|
||
}
|
||
}
|
||
|
||
// 2B-tools: tools do catálogo MCP habilitadas NA TELA entram no run (audit +
|
||
// role/scope da ponte nativa; envio e handoff do catálogo são bloqueados —
|
||
// ver edge/crm/mcp-tools.ts). As 8 tools do engine têm precedência de nome.
|
||
let mcpCleanup: (() => Promise<void>) | null = null;
|
||
try {
|
||
if (agentConfig !== null && agentConfig.toolIds.length > 0) {
|
||
try {
|
||
// As de OPERAÇÃO saem antes de serem montadas, quando o Operador as tem.
|
||
// Medido: são elas que carregavam 2 dos 3 vazamentos (o DADO que devolvem),
|
||
// e tirá-las levou a taxa de 30% para 10% — ver RELATORIO-passo6.md.
|
||
const catalogoEntregue = catalogoEntregueAoOperador({
|
||
operadorLigado: agentConfig.operatorEnabled,
|
||
ferramentasDoOperador: agentConfig.operatorToolIds,
|
||
ferramentasDoConversador: agentConfig.toolIds,
|
||
});
|
||
const configDoTurno =
|
||
catalogoEntregue.length === 0
|
||
? agentConfig
|
||
: {
|
||
...agentConfig,
|
||
toolIds: agentConfig.toolIds.filter((t) => !catalogoEntregue.includes(t)),
|
||
};
|
||
if (catalogoEntregue.length > 0) {
|
||
runLog.info('capacidades de catálogo entregues ao operador', {
|
||
entregues: catalogoEntregue,
|
||
});
|
||
}
|
||
const mcp = await buildMcpTurnTools(
|
||
deps.crmCfg,
|
||
{
|
||
organizationId: tenantId,
|
||
jobId: preview?.runId ?? liveJob().id,
|
||
...(leadId ? { contactId: leadId } : {}),
|
||
},
|
||
configDoTurno,
|
||
runLog,
|
||
preview ? { readOnly: true } : undefined,
|
||
);
|
||
if (mcp !== null) {
|
||
mcpCleanup = mcp.cleanup;
|
||
for (const [name, mcpTool] of Object.entries(mcp.tools)) {
|
||
if (name in rawTools) continue;
|
||
// Marca a EXECUÇÃO (não só a decisão de chamar) — é isso que o agendaStallGate
|
||
// precisa saber para não vetar um turno que já checou a agenda de verdade.
|
||
if (AGENDA_TOOL_NAMES.has(name) && typeof mcpTool.execute === 'function') {
|
||
const executeOriginal = mcpTool.execute.bind(mcpTool);
|
||
rawTools[name] = {
|
||
...mcpTool,
|
||
execute: (async (...args: Parameters<typeof executeOriginal>) => {
|
||
agendaToolCalledThisTurn = true;
|
||
return executeOriginal(...args);
|
||
}) as typeof mcpTool.execute,
|
||
};
|
||
} else {
|
||
rawTools[name] = mcpTool;
|
||
}
|
||
}
|
||
mcpToolIdsDoTurno.push(...mcp.toolIds);
|
||
runLog.info('tools MCP da tela montadas no turno', { mcp_tool_ids: mcp.toolIds });
|
||
}
|
||
} catch (err) {
|
||
// Tool extra é privilégio, não invariante: falha no mint/montagem NÃO
|
||
// derruba o turno — a conversa do cliente não pode morrer porque uma tool
|
||
// extra falhou. Isso continua certo.
|
||
//
|
||
// O que estava errado era o DEPOIS. A versão anterior deste comentário
|
||
// dizia "o humano vê o log". Não vê: o log sai no stdout do worker, num
|
||
// contêiner de VPS que o dono do negócio nunca abre. Medido num turno
|
||
// real — o agente atendeu sem NENHUMA das capacidades que o humano tinha
|
||
// ligado na tela, e a única pista existia num log que ninguém lê. É
|
||
// falha-em-verde: anunciada na tela, ausente na execução, nada contando.
|
||
const detalhe = (err instanceof Error ? err.message : String(err)).slice(0, 200);
|
||
runLog.error('tools MCP da tela não montadas — turno segue sem elas', { error: detalhe });
|
||
if (preview)
|
||
preview.result.impediments.push({
|
||
code: 'capabilities_unavailable',
|
||
message: 'Não foi possível carregar as capacidades configuradas.',
|
||
});
|
||
else await avisarCapacidadesAusentes(pool, tenantId, input.conversationId, detalhe, runLog);
|
||
}
|
||
}
|
||
|
||
// ── A CURA (spec 16, passo 6) ───────────────────────────────────────────────
|
||
//
|
||
// As ferramentas de escrita saem do Conversador quando o Operador as assumiu.
|
||
// O gate de vazamento é rede — barra na saída e ensina; isto é a cura: o
|
||
// modelo não pode repetir o nome de uma ferramenta que nunca viu, e foi pelo
|
||
// NOME que o vazamento voltou depois de a descrição ser limpa.
|
||
//
|
||
// A remoção é CONDICIONAL a o novo dono existir (ver entrega-de-capacidade):
|
||
// tirar de um lado sem garantir o outro não separa papéis, perde capacidade.
|
||
const entregues = capacidadesEntreguesAoOperador({
|
||
operadorLigado: agentConfig?.operatorEnabled ?? false,
|
||
ferramentasDoOperador: agentConfig?.operatorToolIds ?? [],
|
||
});
|
||
for (const nome of entregues) delete rawTools[nome];
|
||
if (entregues.length > 0) {
|
||
runLog.info('capacidades entregues ao operador — fora do turno do conversador', {
|
||
entregues,
|
||
});
|
||
}
|
||
|
||
// Circuit breaker de tools (F2-15): estado no closure DESTA invocação — zera
|
||
// entre runs por construção (mesma garantia de isolamento do resto do run).
|
||
if (preview && rawTools.get_lead_note)
|
||
rawTools.get_lead_note = {
|
||
...rawTools.get_lead_note,
|
||
execute: async () => ({ ok: true, notes: preview.notes ?? [] }),
|
||
};
|
||
const previewContext = preview
|
||
? await previewGateContext(pool, preview, runLog, clock())
|
||
: null;
|
||
const previewTools =
|
||
preview && previewContext
|
||
? applyPreviewPolicy(
|
||
rawTools,
|
||
preview,
|
||
{
|
||
...previewContext,
|
||
disclosure: {
|
||
...previewContext.disclosure,
|
||
mode: deps.knobs.disclosureMode ?? 'inject',
|
||
},
|
||
},
|
||
() => pendingCitations,
|
||
semanticClassifier,
|
||
() => ({
|
||
agenda: {
|
||
active: previewContext.agenda?.active ?? false,
|
||
ferramentas: previewContext.agenda?.ferramentas ?? [],
|
||
toolCalledThisTurn: agendaToolCalledThisTurn,
|
||
},
|
||
}),
|
||
)
|
||
: rawTools;
|
||
const tools = wrapToolsWithBreaker(previewTools, {
|
||
thresholds: deps.knobs.breaker,
|
||
readOnlyTools: READ_ONLY_TOOLS,
|
||
log: runLog, // os warns dos gates do breaker saem carimbados com o run
|
||
});
|
||
|
||
// F3-11: stage-classifier auxiliar. Roda ANTES do turno (modelo BARATO pelo seam
|
||
// agnóstico) e sugere o estágio; a sugestão entra como HINT no SUFIXO por-lead — o modelo
|
||
// do agente decide e confirma via update_lead_state (a máquina F2-10 é a única porta). A
|
||
// sugestão fica guardada para comparar com o que o modelo confirmou (divergência, no fim).
|
||
const currentStage: LeadStage = leadState?.stage ?? 'new';
|
||
let stageSuggestion: LeadStage | null = null;
|
||
let stageHintBlock = '';
|
||
let jailbreakLevel: JailbreakLevel = 'none';
|
||
|
||
// Os dois classificadores auxiliares rodam EM PARALELO, e não em série.
|
||
//
|
||
// Eles são ADVISÓRIOS, leem sinais diferentes (o contexto e o estágio atual
|
||
// vs. a última mensagem do lead) e nenhum consome o resultado do outro — em
|
||
// série o turno pagava duas idas-e-voltas de LLM uma atrás da outra, e o
|
||
// cliente esperava a soma. `Promise.all` paga só a mais lenta das duas.
|
||
//
|
||
// O que NÃO muda por rodar junto: o orçamento mensal da organização é
|
||
// checado dentro de cada `runModelCall` (a mesma checagem que já corre
|
||
// concorrente entre turnos de leads diferentes), nenhuma decisão de
|
||
// guardrail depende de ordem entre os dois, e o `jailbreak` segue sem vetar
|
||
// o inbound — só flagra o turno no trace.
|
||
//
|
||
// A TERCEIRA perna é o Jev na mesma pergunta do jailbreak
|
||
// (`lib/ai/decisao/manipulacao.ts`), e só existe onde ela tem com quem
|
||
// comparar e o que medir: a camada ligada para a organização, fora da
|
||
// prévia — simulação não vira concordância (R5) — e no turno da mensagem
|
||
// NOVA (`inbound_turn`): o `case_reply_turn` responde a ação de um humano
|
||
// sobre uma mensagem que o turno dela já perguntou. A tarefa desligada, o
|
||
// interruptor e o aceite são conferidos lá dentro. Em paralelo, o turno só
|
||
// espera por ele o que ele passar do mais lento dos dois.
|
||
//
|
||
// O Jev recebe o que o CLIENTE digitou, e não o `skillSignal`: numa mídia, o
|
||
// `skillSignal` leva a transcrição, a descrição ou o texto do PDF e a moldura
|
||
// de instrução do agente, que o aceite ("cada mensagem, sozinha") não cobre
|
||
// (R4). Mídia fica de fora da pergunta dele.
|
||
const manipulacaoLigada = camadaLigada(camadas.jailbreak, deps.knobs.jailbreak !== undefined);
|
||
const perguntaAoJev =
|
||
manipulacaoLigada && !preview && job?.kind === 'inbound_turn'
|
||
? perguntarManipulacaoAoJev(
|
||
pool,
|
||
{
|
||
organizationId: tenantId,
|
||
mensagem: textoDoClienteNaUltimaMensagem(effectiveContext.messages),
|
||
contactId: leadId || null,
|
||
jobId: job.id,
|
||
},
|
||
deps.jev,
|
||
)
|
||
: Promise.resolve(null);
|
||
const gravarOJev = async (
|
||
doJev: ManipulacaoDoJev | null,
|
||
nivelDaIa: JailbreakLevel | null,
|
||
nivelFinal: JailbreakLevel,
|
||
): Promise<void> => {
|
||
if (doJev === null) return;
|
||
await registrarManipulacaoDoJev(pool, {
|
||
organizationId: tenantId,
|
||
contactId: leadId || null,
|
||
conversationId: input.conversationId || null,
|
||
messageId: input.inboundMessageId ?? null,
|
||
jobId: job?.id ?? null,
|
||
jev: doJev,
|
||
nivelDaIa,
|
||
nivelFinal,
|
||
});
|
||
};
|
||
const [stageResultado, jailbreakVerdict, manipulacaoDoJev] = await Promise.all([
|
||
deps.knobs.stageClassifier !== undefined
|
||
? classifyStage(
|
||
pool,
|
||
deps.llmCfg,
|
||
{ tenantId, leadId: leadId || null, jobId: job?.id },
|
||
{
|
||
context: effectiveContext,
|
||
currentStage,
|
||
...argsAux(deps.knobs.stageClassifier.model),
|
||
},
|
||
{ registry: deps.registry, log: runLog },
|
||
)
|
||
: Promise.resolve(null),
|
||
// F4-04: classifier ADVISÓRIO anti-jailbreak sobre a mensagem INBOUND do lead (o
|
||
// skillSignal já é a última inbound). Roda pelo seam agnóstico (modelo BARATO, budget
|
||
// checado nele). NÃO veta o inbound — só FLAGRA o turno no trace; flag/level não são PII
|
||
// (a mensagem/reason nunca vão a log). A correlação com promessa fora de tabela escala no fim.
|
||
manipulacaoLigada
|
||
? classifyJailbreak(
|
||
pool,
|
||
deps.llmCfg,
|
||
{ tenantId, leadId: leadId || null, jobId: job?.id },
|
||
{
|
||
message: skillSignal,
|
||
// Knob ausente + organização ligando = roda com o modelo padrão dela,
|
||
// que é a convenção já usada pelo stageClassifier.
|
||
...argsAux(deps.knobs.jailbreak?.model),
|
||
},
|
||
{ registry: deps.registry, log: runLog },
|
||
)
|
||
: Promise.resolve(null),
|
||
perguntaAoJev,
|
||
]).catch(async (err: unknown) => {
|
||
// O teto de orçamento derruba o classificador de sempre (`LlmBudgetExceededError`
|
||
// sobe para a escolta do turno), mas não o Jev (R8): a chamada dele já saiu
|
||
// e foi cobrada, e o custo entra em `llm_calls` — a observação vai sem par,
|
||
// porque a IA de sempre não decidiu. `perguntaAoJev` nunca rejeita.
|
||
await gravarOJev(await perguntaAoJev, null, 'none');
|
||
throw err;
|
||
});
|
||
|
||
stageSuggestion = stageResultado;
|
||
if (stageSuggestion !== null) {
|
||
stageHintBlock = renderStageHint(stageSuggestion, currentStage);
|
||
}
|
||
|
||
// Observando, vale o nível da IA de sempre; decidindo, o maior dos dois; e
|
||
// sem veredito da IA de sempre, `none` — nunca o Jev no lugar dela (R2).
|
||
jailbreakLevel = nivelFinalDaManipulacao(jailbreakVerdict, manipulacaoDoJev);
|
||
if (jailbreakLevel !== 'none') {
|
||
// trace do turno: só flag/level (não PII) — a mensagem e o reason nunca são logados.
|
||
runLog.warn('jailbreak: sinal detectado na mensagem do lead', {
|
||
jailbreak_flag: true,
|
||
jailbreak_level: jailbreakLevel,
|
||
...(jailbreakLevel !== jailbreakVerdict?.level ? { jailbreak_somado_pelo_jev: true } : {}),
|
||
});
|
||
}
|
||
await gravarOJev(
|
||
manipulacaoDoJev,
|
||
jailbreakVerdict === null || jailbreakVerdict.falhou ? null : jailbreakVerdict.level,
|
||
jailbreakLevel,
|
||
);
|
||
|
||
// Spec 16 §4: a projeção arma quando NENHUMA ferramenta de catálogo entrou —
|
||
// é exatamente o turno em que os ids do contexto não têm uso, e portanto o
|
||
// único em que removê-los não custa nada. Logado porque "por que o prompt
|
||
// deste turno é diferente do daquele?" precisa ter resposta no trace.
|
||
const projetaContexto = turnoProjeta(mcpToolIdsDoTurno);
|
||
runLog.info('projeção do contexto do turno', {
|
||
projeta: projetaContexto,
|
||
mcp_tools_no_turno: mcpToolIdsDoTurno.length,
|
||
});
|
||
const openingBase = input.buildOpening({
|
||
previous: effectivePrevious,
|
||
leadState,
|
||
context: effectiveContext,
|
||
notesIndexBlock,
|
||
projeta: projetaContexto,
|
||
entregues,
|
||
compromissosBlock,
|
||
...(currentInboundText !== null ? { currentInboundText } : {}),
|
||
});
|
||
// Sufixos por-lead (situacionais, voláteis — depois do prefixo cacheável F2-17): corpos de
|
||
// skill casadas (F3-09) + hint do classificador (F3-11) + instrução de split (F4-xx, quando
|
||
// split_messages está on — Onda 4). Vazios são omitidos.
|
||
const splitHint = instrucaoDeBolhas(agentConfig?.splitMessages ?? false);
|
||
// Spec 15: o `case_id` real do caso 'awaiting_lead' desta conversa, se houver — sem
|
||
// isso o modelo nunca consegue chamar provide_case_update quando o lead simplesmente
|
||
// responde (o caminho comum; case_reply_turn só cobre a AÇÃO do humano). Sufixo
|
||
// por-lead (volátil) — nunca no prefixo cacheável (o case_id muda a cada caso).
|
||
const caseAwaitingLead =
|
||
preview?.kind !== 'sandbox' && agentConfig !== null && agentConfig.casesEnabled
|
||
? await getCaseAwaitingLead(pool, tenantId, input.conversationId)
|
||
: null;
|
||
const caseAwaitingLeadBlock =
|
||
caseAwaitingLead !== null
|
||
? `## Caso aguardando resposta deste cliente\n` +
|
||
`Há um caso aberto (case_id: ${caseAwaitingLead.id}) esperando uma informação dele: "${caseAwaitingLead.ask}". ` +
|
||
`Se a mensagem dele responde a isso, chame provide_case_update com este case_id e a informação recebida — ` +
|
||
`NÃO diga que já repassou/avisou o responsável sem chamar a tool.`
|
||
: '';
|
||
// ── O RELÓGIO DO TURNO ────────────────────────────────────────────────────
|
||
//
|
||
// Entra AQUI, e o lugar é a metade do conserto.
|
||
//
|
||
// No `system` (o prefixo estável org-wide, F2-17) ele invalidaria o cache de
|
||
// prompt de TODOS os leads a cada turno, porque muda a cada segundo — é o que
|
||
// `stable-prefix.ts` proíbe em letra. No sufixo por-lead ele é volátil entre
|
||
// iguais, e custa os ~50 tokens dele.
|
||
//
|
||
// PRIMEIRO da lista de propósito: a âncora temporal precede o material que o
|
||
// modelo vai usar para decidir data — corpo de skill, hint do classificador,
|
||
// caso pendente. E `executarTurnoDoAgente` é o ponto por onde passam os TRÊS
|
||
// turnos conversacionais (inbound, follow-up e resposta a caso), então um
|
||
// ponto só cobre os três — e alcança de carona a chamada de fechamento, que
|
||
// reusa `openingTextOnly` e é onde nasce o `prazo` ISO da declaração.
|
||
const agoraBlock = renderAgora(clock(), fusoDaOrg);
|
||
const openingSuffixes = [
|
||
agoraBlock,
|
||
matchedSkillsBlock,
|
||
stageHintBlock,
|
||
splitHint,
|
||
caseAwaitingLeadBlock,
|
||
roteiro?.bloco ?? '',
|
||
preview?.feedback ? '## Revisão humana deste atendimento\n' + preview.feedback : '',
|
||
].filter((b) => b !== '');
|
||
const openingText =
|
||
openingSuffixes.length === 0
|
||
? openingBase
|
||
: `${openingBase}\n\n${openingSuffixes.join('\n\n')}`;
|
||
// Onda 3 (aprimoramento): mídia inbound recente vira part nativa (image/file) SÓ para
|
||
// provider+modelo capazes (T2 modelCapabilities) — modelo incapaz/desconhecido → [] e o
|
||
// derivado textual (já embutido em openingText via LeadContextMessage) cobre sozinho.
|
||
const nativeParts =
|
||
preview?.kind === 'sandbox'
|
||
? []
|
||
: await buildNativeMediaParts({
|
||
messages: effectiveContext.messages,
|
||
provider: agentConfig?.provider ?? 'anthropic',
|
||
model: agentConfig?.model ?? '',
|
||
multimodalInput: agentConfig?.multimodalInput ?? false,
|
||
admin: deps.crmCfg.supabase,
|
||
});
|
||
const openingTextOnly: ModelMessage[] = [{ role: 'user', content: openingText }];
|
||
const openingMessages: ModelMessage[] =
|
||
nativeParts.length === 0
|
||
? openingTextOnly
|
||
: [{ role: 'user', content: [{ type: 'text', text: openingText }, ...nativeParts] }];
|
||
|
||
// "digitando…" ENQUANTO o modelo pensa. A pausa humana antes da 1ª bolha
|
||
// (`esperaForaDoLock`) desconta este tempo e quase sempre zera — e com espera
|
||
// zero ela não acende presença. Sem esta linha o cliente esperava a chamada
|
||
// inteira do modelo sem indicador nenhum. Só em turno que fala com o lead:
|
||
// turno de retaguarda não abre o WhatsApp de ninguém.
|
||
if (channel?.signalTyping && turnoVaiFalarComOLead(liveJob())) {
|
||
acenderDigitando(
|
||
() => channel.signalTyping!({ tenantId, conversationId: input.conversationId }),
|
||
runLog,
|
||
);
|
||
}
|
||
|
||
// O modelo decide tools livremente dentro do teto de steps (knob AGENT_MAX_STEPS).
|
||
//
|
||
// Sem escolta LOCAL: quem cobre o teto de gasto é `runAgentTurn`, que envolve
|
||
// este corpo inteiro. Escoltar aqui deixaria de fora as chamadas de modelo dos
|
||
// auxiliares (`classifyStage`, `maybeCompact`), que rodam ANTES desta e por
|
||
// isso são as que estouram primeiro.
|
||
const turn = await runModelCall(
|
||
pool,
|
||
deps.llmCfg,
|
||
{
|
||
tenantId,
|
||
leadId: leadId || null,
|
||
jobId: job?.id,
|
||
// De quem é esta execução. Vai para `llm_calls.agent_id` e é o que permite
|
||
// a aba "Execuções" da tela do agente mostrar o que ELE fez — antes ela
|
||
// lia `ai_agent_runs`, tabela que motor nenhum vivo escreve, e dizia
|
||
// "Nenhuma execução ainda" com o agente respondendo no WhatsApp.
|
||
agentId: agentConfig?.agentId ?? null,
|
||
purpose: preview ? 'agent_preview' : 'agent_turn',
|
||
system,
|
||
messages: openingMessages,
|
||
tools,
|
||
maxSteps,
|
||
// Rascunho: a resposta é o send_message ACEITO; a etapa seguinte só
|
||
// "encerrava". Aceito, e não chamado: o envio vetado pela cadeia
|
||
// before_send volta ao modelo para ele reescrever (o 1º veto ensina).
|
||
...(preview?.kind === 'assisted'
|
||
? { pararQuando: () => preview.result.candidates.length > 0 }
|
||
: {}),
|
||
...(agentConfig !== null
|
||
? {
|
||
model: agentConfig.model,
|
||
llmOverride: {
|
||
provider: agentConfig.provider,
|
||
credentialId: agentConfig.credentialId,
|
||
},
|
||
}
|
||
: {}),
|
||
},
|
||
{ registry: deps.registry, log: runLog },
|
||
);
|
||
|
||
// F4-04: correlação dos dois sinais do MESMO turno — jailbreak ALTO + tentativa de
|
||
// promessa fora de tabela (F4-01). Ambos estão determinados aqui (o jailbreak rodou na
|
||
// abertura; as tentativas de envio já passaram pelo loop). Dispara escalação humana em
|
||
// inbox_items (dedup por episódio). Advisório: o classifier sozinho nunca escala — o gate
|
||
// determinístico é que confirma a promessa indevida. Feito antes do runError/veto para
|
||
// não se perder num turno que falha o envio depois.
|
||
if (!preview && jailbreakLevel === JAILBREAK_ESCALATION_LEVEL && outOfTablePromiseAttempted) {
|
||
const created = await escalateJailbreakPromise(pool, {
|
||
tenantId,
|
||
leadId,
|
||
level: jailbreakLevel,
|
||
});
|
||
if (created > 0) {
|
||
runLog.warn(
|
||
'jailbreak: escalação humana criada (flag alta + promessa fora de tabela no turno)',
|
||
{
|
||
jailbreak_level: jailbreakLevel,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
if (runError !== null) {
|
||
throw runError; // job falha → retry da fila; o ledger segura duplicata de envio
|
||
}
|
||
if (outcomes.some((o) => o.kind === 'failed')) {
|
||
// ponytail: retry re-roda o run inteiro (LLM incluso); seq N re-encontra a
|
||
// linha do ledger — 'accepted' pula, 'failed' rotaciona a key (F2-06).
|
||
throw new Error('envio marcado como failed pelo CRM — run re-tentado pela fila');
|
||
}
|
||
|
||
// ROTEIRO: a pergunta pendente é compromisso. Se o modelo não a fez, o motor
|
||
// a manda — pela MESMA cadeia de guardrails, dentro do teto de envios. Roda
|
||
// mesmo com o teto cheio: registrar que o MODELO fez a pergunta é o que a
|
||
// torna "a pergunta atual" no turno seguinte.
|
||
if (roteiro !== null) {
|
||
await garantirPerguntaDoRoteiro(
|
||
{ pool, log: runLog },
|
||
{
|
||
organizationId: tenantId,
|
||
roteiro,
|
||
corposEnviados,
|
||
enviar: async (texto) => {
|
||
// #1943: turno descartado como obsoleto → a pergunta do roteiro NÃO
|
||
// sai (segue pendente para o turno seguinte). Também vigia o teto
|
||
// de mensagens físicas do turno (F2-15b).
|
||
if (!perguntaDoRoteiroPodeSair({ turnoDescartado, seq, maxSendsPerTurn })) return false;
|
||
const chain = await runBeforeSend({
|
||
pool,
|
||
log: runLog,
|
||
agentOperation,
|
||
tenantId,
|
||
leadId,
|
||
jobId: liveJob().id,
|
||
channelSessionId: input.channelSessionId,
|
||
body: texto,
|
||
optedOutThisTurn,
|
||
// Sai no MESMO turno da resposta: sem isto, às 3h com a janela de
|
||
// resposta aberta, o agente responde e a pergunta do roteiro é vetada.
|
||
resposta: eTurnoDeResposta(liveJob()),
|
||
crmDailyLimit: null,
|
||
// A pergunta repete por design (foi feita e não respondida); o
|
||
// anti-blast vetaria justamente o que esta trava garante. Mesmo
|
||
// motivo do aviso de escalação.
|
||
enforceSpinning: false,
|
||
now: clock(),
|
||
sleep: deps.sleep,
|
||
lgpd,
|
||
...(deps.knobs.disclosureMode !== undefined
|
||
? { disclosureMode: deps.knobs.disclosureMode }
|
||
: {}),
|
||
send: (finalBody: string) => {
|
||
seq += 1;
|
||
return liveChannel().send({
|
||
tenantId,
|
||
leadId,
|
||
jobId: liveJob().id,
|
||
jobClaim: claimOfJob(liveJob()),
|
||
agentOperation,
|
||
seq,
|
||
conversationId: input.conversationId,
|
||
body: finalBody,
|
||
});
|
||
},
|
||
});
|
||
return chain.status !== 'vetoed' && (chain.outcome.kind === 'sent' || chain.outcome.kind === 'already_sent');
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
// F3-10: poda os tool results antigos da fita do run ANTES de reenviá-los no fechamento
|
||
// (é onde a fita inteira é re-serializada num prompt) — o conteúdo durável já foi para
|
||
// lead_notes pelo flush (F3-07), então o stub não perde nada recuperável. Opera SÓ no
|
||
// sufixo por-lead, nunca no prefixo estável (regra de cache 15).
|
||
const responseMessages =
|
||
deps.knobs.prune !== undefined
|
||
? pruneToolResults(turn.result.response.messages, deps.knobs.prune)
|
||
: turn.result.response.messages;
|
||
|
||
// Fechamento imposto pelo runtime: 2ª chamada, mesma conversa, só o checkpoint.
|
||
//
|
||
// Também sob o handoff (o do turno inteiro, em `runAgentTurn`): o teto pode
|
||
// ser cruzado ENTRE as duas chamadas — a primeira é que gasta o grosso do
|
||
// turno. Aqui o lead já recebeu resposta, mas a conversa ficaria sem
|
||
// checkpoint e sem dono, e o próximo inbound cairia no mesmo bloqueio, agora
|
||
// sem nada tendo mudado no meio.
|
||
// Prévia sem candidato e sem impedimento: quem opera precisa saber que o agente não propôs nada.
|
||
const avisarSemCandidato = (p: NonNullable<typeof preview>): void => {
|
||
if (p.result.candidates.length === 0 && p.result.impediments.length === 0)
|
||
p.result.impediments.push({
|
||
code: 'no_candidate',
|
||
message: 'O agente não propôs uma resposta. Revise o cenário ou a configuração.',
|
||
});
|
||
};
|
||
// ⚠️ RASCUNHO (modo assistido) não fecha o turno com checkpoint. O checkpoint
|
||
// da prévia não é gravado (a prévia retorna antes do `insertCheckpoint`, logo
|
||
// abaixo) e o `reply-drafts.ts` não o lê — só a prévia de TESTE (sandbox) o
|
||
// mostra na tela. Mesmo assim, a chamada de fechamento segurava a entrega do
|
||
// rascunho: medido em produção (gpt-6-luna, 2026-09-24), resposta pronta às
|
||
// 12:32:40 e rascunho entregue às 12:32:56 — 16 dos 28 s que o operador
|
||
// esperava depois de clicar em "Sugerir resposta".
|
||
if (preview?.kind === 'assisted') {
|
||
avisarSemCandidato(preview);
|
||
return;
|
||
}
|
||
const closing = await runModelCall(
|
||
pool,
|
||
deps.llmCfg,
|
||
{
|
||
tenantId,
|
||
leadId: leadId || null,
|
||
jobId: job?.id,
|
||
purpose: 'checkpoint',
|
||
...(agentConfig !== null
|
||
? {
|
||
model: agentConfig.model,
|
||
llmOverride: {
|
||
provider: agentConfig.provider,
|
||
credentialId: agentConfig.credentialId,
|
||
},
|
||
}
|
||
: {}),
|
||
system,
|
||
messages: [
|
||
// prune: o checkpoint reusa a abertura só como texto — a mídia nativa (cara) já
|
||
// fez seu trabalho na 1ª chamada e não precisa ir de novo.
|
||
...openingTextOnly,
|
||
...responseMessages,
|
||
{ role: 'user', content: CHECKPOINT_INSTRUCTION },
|
||
],
|
||
},
|
||
{ registry: deps.registry, log: runLog },
|
||
);
|
||
const content = parseCheckpointText(
|
||
closing.result.text.replace(
|
||
/https:\/\/meet\.google\.com\/[a-zA-Z0-9-]+/g,
|
||
'[link da reunião disponível na Agenda]',
|
||
),
|
||
);
|
||
|
||
if (preview) {
|
||
preview.result.checkpoint = content;
|
||
avisarSemCandidato(preview);
|
||
return;
|
||
}
|
||
|
||
// Wave 3 (2.4): o checkpoint anterior é lido ANTES de gravar o novo — a
|
||
// timeline recebe o DIFF, nunca o snapshot. Emitir a cada turno encheria a
|
||
// tela com "a IA pensou" e enterraria a única linha que muda o que alguém
|
||
// faria a seguir.
|
||
const checkpointAnterior = await latestCheckpoint(pool, tenantId, leadId);
|
||
await insertCheckpoint(pool, { tenantId, leadId, jobId: liveJob().id, content });
|
||
|
||
// ── O TURNO DO OPERADOR (spec 16 §3.2) ─────────────────────────────────────
|
||
//
|
||
// Enfileirado AQUI, pelo RUNTIME, logo depois de o checkpoint existir — nunca
|
||
// por decisão do modelo. Um Conversador que "chama" o Operador devolveria o
|
||
// problema inteiro: voltaria a depender de o modelo lembrar, e o turno em que
|
||
// ele não achasse necessário seria um lead parado no funil, em silêncio.
|
||
//
|
||
// Depois do checkpoint porque a declaração É o insumo do Operador; enfileirar
|
||
// antes criaria uma corrida em que ele leria o checkpoint do turno ANTERIOR e
|
||
// agiria sobre um turno que não é o seu.
|
||
//
|
||
// Fire-and-forget: falha ao enfileirar NÃO derruba um turno que já respondeu
|
||
// ao cliente. O `sourceEventId` é o job do Conversador, então o retry da fila
|
||
// não gera um segundo Operador para o mesmo turno.
|
||
const disparo = decidirSeEnfileiraOperador({
|
||
temAgentePublicado: agentConfig !== null,
|
||
papelLigado: agentConfig?.operatorEnabled ?? false,
|
||
});
|
||
if (!disparo.enfileira) {
|
||
runLog.info('turno do operador não enfileirado', { porque: disparo.porque });
|
||
} else {
|
||
try {
|
||
const { deduped } = await enqueueJob(pool, tenantId, {
|
||
kind: 'operator_turn',
|
||
leadId,
|
||
sourceEventId: liveJob().id,
|
||
payload: {
|
||
conversation_id: input.conversationId,
|
||
origin_job_id: liveJob().id,
|
||
agent_id: agentConfig?.agentId ?? null,
|
||
},
|
||
});
|
||
runLog.info('turno do operador enfileirado', { deduped });
|
||
} catch (err) {
|
||
runLog.error('turno do operador NÃO foi enfileirado (o turno segue)', {
|
||
error: (err instanceof Error ? err.message : String(err)).slice(0, 120),
|
||
});
|
||
// O CATCH TINHA A DOUTRINA CERTA E A CONCLUSÃO ERRADA.
|
||
//
|
||
// "O aviso não pode derrubar o turno que já respondeu ao cliente" está certo.
|
||
// "Então basta um log" não: o enfileiramento é o ÚNICO mecanismo que garante o
|
||
// disparo do papel, e falhar aqui significa que a promessa que o Conversador
|
||
// acabou de fazer não terá dono e ninguém vai saber.
|
||
//
|
||
// É a mesma lição que este arquivo já aplicou no catch das capacidades MCP,
|
||
// onde o comentário diz que a versão anterior "dizia 'o humano vê o log'. Não
|
||
// vê." Lição aplicada numa ocorrência e não na irmã.
|
||
//
|
||
// Só quando HÁ promessa: sem ela o Operador teria decidido "nada a fazer", e
|
||
// item sem ação é ruído — ruído ensina a ignorar a Central.
|
||
const promessas = promessasEmAberto(content.declaracao ?? null);
|
||
if (promessas.length > 0) {
|
||
try {
|
||
await insertInboxItem(
|
||
pool,
|
||
tenantId,
|
||
{
|
||
kind: 'promise_unfulfilled',
|
||
severity: 'warn',
|
||
title: 'Um retorno prometido a um cliente ficou sem dono',
|
||
body:
|
||
'O assistente prometeu algo a esta pessoa nesta conversa e o passo que registra ' +
|
||
'o cumprimento não chegou a ser agendado. Abra a conversa, veja o que foi ' +
|
||
'combinado e cumpra você mesmo.',
|
||
refKind: 'conversation',
|
||
refId: input.conversationId,
|
||
},
|
||
'kind_e_ref',
|
||
);
|
||
} catch (erroDoAviso) {
|
||
runLog.error('aviso de promessa sem dono não foi gravado', {
|
||
error: (erroDoAviso instanceof Error
|
||
? erroDoAviso.message
|
||
: String(erroDoAviso)
|
||
).slice(0, 120),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const mudanca = diffCheckpoint(
|
||
checkpointAnterior
|
||
? {
|
||
commitments: (checkpointAnterior.commitments ?? []) as string[],
|
||
objections: (checkpointAnterior.objections ?? []) as string[],
|
||
next_action: checkpointAnterior.next_action ?? null,
|
||
rolling_summary: checkpointAnterior.rolling_summary ?? null,
|
||
}
|
||
: null,
|
||
content,
|
||
);
|
||
|
||
if (mudanca.emit) {
|
||
try {
|
||
const r = await emitAgentActivityForContact({
|
||
pool,
|
||
organizationId: tenantId,
|
||
contactId: leadId,
|
||
type: 'ai_turn',
|
||
sourceModule: 'agent',
|
||
sourceId: liveJob().id,
|
||
// O lastro é a chamada de modelo que PRODUZIU este checkpoint
|
||
// (llm_calls.id). Sem ele a linha entraria como 'system' e perderia a
|
||
// autoria justamente no evento mais "de IA" que existe.
|
||
...(closing.callId ? { evidence: { llm_call_ids: [closing.callId] } } : {}),
|
||
...(agentConfig?.agentId ? { agentId: agentConfig.agentId } : {}),
|
||
reason: mudanca.reason,
|
||
payload: {
|
||
added_commitments: mudanca.addedCommitments,
|
||
added_objections: mudanca.addedObjections,
|
||
next_action_changed: mudanca.nextActionChanged,
|
||
},
|
||
});
|
||
if (!r.routed) {
|
||
runLog.info('checkpoint sem negócio para pendurar: registrado no event_log', {
|
||
reason: r.reason,
|
||
});
|
||
}
|
||
} catch (err) {
|
||
// A timeline do turno não pode derrubar o turno.
|
||
runLog.error('falha ao registrar atividade de checkpoint (segue)', {
|
||
error: err instanceof Error ? err.name : 'unknown',
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── A NOTA DO NEGÓCIO ──────────────────────────────────────────────────────
|
||
//
|
||
// O turno acabou de mexer em TUDO que a fórmula lê: compromissos e objeções
|
||
// (o checkpoint acima) e a qualificação BANT (`lead_state`, escrita pelo
|
||
// update_lead_state do modelo). Recalcular aqui é recalcular no instante em
|
||
// que os sinais mudaram — não há evento melhor.
|
||
//
|
||
// ⚠️ POR QUE ISTO EXISTE: `recalculaScoreDoLead` estava escrita, testada e
|
||
// com constraint no banco exigindo o `reason` — e SEM UM ÚNICO CHAMADOR no
|
||
// repositório inteiro. Nenhuma nota jamais foi calculada. O modo de falha era
|
||
// mudo: o card simplesmente não mostrava número, e "não tem nota ainda" é
|
||
// indistinguível de "ninguém nunca calcula".
|
||
//
|
||
// Fora do `if (mudanca.emit)` DE PROPÓSITO: o BANT muda em turnos que não
|
||
// mexem no checkpoint, e esses turnos também mudam a nota. Amarrar o cálculo
|
||
// à emissão da atividade faria a nota envelhecer em silêncio — o mesmo
|
||
// defeito, um andar acima.
|
||
//
|
||
// Falha aqui não derruba o turno: nota é derivado, e o próximo turno
|
||
// recalcula. O que não pode é o cliente ficar sem resposta por causa dela.
|
||
try {
|
||
const alvo = await resolveActiveLeadForContact(
|
||
(
|
||
await pool.query<LeadCandidate>(
|
||
`select l.id, l.organization_id, l.pipeline_id, l.status,
|
||
l.last_activity_at, l.created_at
|
||
from crm_leads l
|
||
where l.organization_id = $1 and l.contact_id = $2`,
|
||
[tenantId, leadId],
|
||
)
|
||
).rows,
|
||
);
|
||
if (alvo.routed) {
|
||
const r = await recalculaScoreDoLead(pool, tenantId, alvo.leadId);
|
||
runLog.info('score do negócio recalculado', {
|
||
lead_id: alvo.leadId,
|
||
gravou: r.gravou,
|
||
...(r.motivo !== undefined ? { motivo: r.motivo } : {}),
|
||
});
|
||
}
|
||
} catch (err) {
|
||
runLog.error('falha ao recalcular score (segue)', {
|
||
error: err instanceof Error ? err.name : 'unknown',
|
||
});
|
||
}
|
||
|
||
// F3-11: divergência classificador×modelo. O classificador sugeriu um estágio; se o
|
||
// modelo confirmou (via update_lead_state — a máquina F2-10) um estágio DIFERENTE, o
|
||
// desacordo vira candidato ao golden set (linha em `golden_candidates` — mesma tabela
|
||
// da F3-09, migration 0428). Sem sugestão, sem confirmação, ou concordância ⇒ nenhuma
|
||
// linha (zero divergência).
|
||
if (
|
||
deps.knobs.goldenCandidates === true &&
|
||
stageSuggestion !== null &&
|
||
confirmedStage !== null &&
|
||
stageSuggestion !== confirmedStage
|
||
) {
|
||
await recordStageDivergenceCandidate(
|
||
pool,
|
||
{
|
||
tenantId,
|
||
leadId,
|
||
jobId: liveJob().id,
|
||
divergence: { suggested: stageSuggestion, confirmed: confirmedStage },
|
||
},
|
||
runLog,
|
||
);
|
||
}
|
||
|
||
const blocked = outcomes.find((o) => o.kind === 'blocked');
|
||
if (blocked !== undefined) {
|
||
// veto permanente (regra dura nº 2): cancela o job e cacheia o opt-out —
|
||
// depois do checkpoint (o artefato do turno fica registrado mesmo em veto).
|
||
await applySendOutcome(
|
||
pool,
|
||
blocked,
|
||
{
|
||
jobId: liveJob().id,
|
||
workerId: ctx.workerId,
|
||
tenantId,
|
||
leadId,
|
||
jobClaim: claimOfJob(liveJob()),
|
||
},
|
||
{ queuedRetryDelayMs: deps.knobs.queuedRetryDelayMs },
|
||
);
|
||
throw new JobSettledError(
|
||
'turno encerrado com veto do sink (is_blocked) — job cancelado em definitivo, checkpoint gravado',
|
||
);
|
||
}
|
||
|
||
// Cap de warm-up/diário vetou toda tentativa de envio deste turno e nada saiu: sem
|
||
// isto, o job terminava 'ok' com `messages_sent: 0` e o lead ficava sem resposta até
|
||
// escrever de novo por conta própria (ou nunca) — medido em produção, 2026-08-29
|
||
// (número no dia 0 de warm-up, cap batido pelo volume da própria conversa de teste).
|
||
// Mesmo contrato da janela anti-ban (linha ~1233): adia sem gastar `attempts`, o job
|
||
// volta a 'pending' na hora certa e o mesmo turno roda de novo, com o mesmo contexto.
|
||
if (pacingCapVeto !== null && outcomes.length === 0) {
|
||
// Capturado num `const`: `pacingCapVeto` é reatribuído numa closure em outro ponto do
|
||
// turno, e o TS reabre a união (perde o `!== null`) depois de qualquer chamada — o
|
||
// valor JÁ CHECADO não muda, só a inferência precisa de um nome que não reatribui.
|
||
const veto = pacingCapVeto;
|
||
await rescheduleJob(pool, liveJob().id, ctx.workerId, {
|
||
acquiredAt: claimOfJob(liveJob())?.acquired_at,
|
||
delayMs: Math.max(veto.nextAllowedAt.getTime() - clock().getTime(), 1_000),
|
||
reason: `cap de envio (${veto.code}) atingido — turno adiado para a próxima abertura`,
|
||
});
|
||
runLog.info('turno adiado — cap de envio atingido antes de qualquer mensagem sair', {
|
||
code: veto.code,
|
||
proxima_abertura: veto.nextAllowedAt.toISOString(),
|
||
});
|
||
// O reagendamento acima trata toda mensagem represada igual — um lead relatando
|
||
// risco de segurança (freio, fumaça, bateria esquentando) esperaria a mesma janela
|
||
// que um "bom dia" qualquer, às vezes horas (medido num tenant de produção:
|
||
// 20h+ represado num relato de bateria superaquecendo). Sem furar o cap de
|
||
// warm-up/diário em si (proteção anti-banimento — mexer nisso é decisão de
|
||
// produto, não deste guardrail), abre um alerta CRÍTICO na Central agora, pra um
|
||
// humano poder responder manualmente pelo próprio WhatsApp enquanto o número
|
||
// aquece. Dedupe por (kind, ref) — não reabre um já aberto pra esta conversa.
|
||
if (inboundsPendentes.some((texto) => detectUrgencySignal(texto))) {
|
||
await insertInboxItem(
|
||
pool,
|
||
tenantId,
|
||
{
|
||
kind: 'handoff',
|
||
severity: 'critical',
|
||
title: 'Lead com sinal de urgência represado pelo cap de envio do número',
|
||
body:
|
||
`Mensagem do lead parece relatar risco/urgência, mas o número está em ` +
|
||
`warm-up/bateu o cap diário (${veto.code}) — a resposta automática só sai em ` +
|
||
`${veto.nextAllowedAt.toISOString()}. Considere responder manualmente pelo ` +
|
||
`WhatsApp enquanto o número aquece.`,
|
||
refKind: 'conversation',
|
||
refId: input.conversationId,
|
||
},
|
||
'kind_e_ref',
|
||
).catch((err) => {
|
||
runLog.warn('alerta de urgência represada por warmup_cap falhou (best-effort)', {
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
});
|
||
}
|
||
throw new JobSettledError(
|
||
'cap de envio atingido — job reagendado para a próxima abertura, sem mensagem enviada',
|
||
);
|
||
}
|
||
|
||
runLog.info('turno do agente concluído', {
|
||
kind: liveJob().kind,
|
||
messages_sent: outcomes.length,
|
||
model: turn.model,
|
||
});
|
||
} finally {
|
||
await mcpCleanup?.();
|
||
}
|
||
}
|
||
|
||
export async function runAgentPreview(
|
||
deps: InboundTurnDeps,
|
||
pool: pg.Pool,
|
||
preview: TurnPreview,
|
||
): Promise<void> {
|
||
await executarTurnoDoAgente(
|
||
deps,
|
||
null,
|
||
pool,
|
||
{ workerId: '' },
|
||
{
|
||
channelSessionId: preview.channelId ?? '',
|
||
conversationId: preview.context.context.conversation_id ?? '',
|
||
buildOpening: ({
|
||
previous,
|
||
leadState,
|
||
context,
|
||
notesIndexBlock,
|
||
projeta,
|
||
entregues,
|
||
compromissosBlock,
|
||
}) =>
|
||
buildOpeningMessage(
|
||
previous,
|
||
leadState,
|
||
context,
|
||
notesIndexBlock,
|
||
projeta,
|
||
entregues,
|
||
compromissosBlock,
|
||
),
|
||
},
|
||
preview,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Handler de `inbound_turn` para o registry do daemon (main.ts): o lead mandou uma
|
||
* mensagem. Ids de envio vêm do payload do drain (fonte confiável — F2-05); a
|
||
* abertura é o ritual padrão, sem bloco temporal.
|
||
*/
|
||
export function createInboundTurnHandler(deps: InboundTurnDeps) {
|
||
return async (job: JobRow, pool: pg.Pool, ctx: { workerId: string }): Promise<void> => {
|
||
const payload = inboundTurnPayloadSchema.parse(job.payload);
|
||
if (!job.contact_id) throw new Error('reply_without_contact');
|
||
// AS TRAVAS VÊM ANTES DE ESCOLHER O AGENTE, e é aqui que elas precisam estar.
|
||
//
|
||
// Escolher o agente pergunta à IA de sempre e, com a tarefa do roteador do
|
||
// Jev rodando, manda a mensagem ao Jev — um fornecedor nos EUA, com aceite
|
||
// próprio — e grava a observação. Com as travas depois, a conversa que
|
||
// nenhum agente vai atender (lead em handoff, `force_human`, bot
|
||
// silenciado, dono humano, número fora da lista de teste no pré-go-live)
|
||
// saía mesmo assim: o drain só barra a conversa não elegível quando NÃO há
|
||
// agente assistido (`canAssist`, drain.ts), e o operador que limitou a IA a
|
||
// números de teste via a mensagem de cliente real ir para fora.
|
||
//
|
||
// O ASSISTIDO também passa por aqui: o drain desliga o gate quando a org
|
||
// tem agente assistido publicado no canal — de propósito, o rascunho é o
|
||
// produto do modo assistido e barrar no drain o mataria —, e o ramo
|
||
// assistido abaixo devolve ANTES de `runAgentTurn`, onde moram as mesmas
|
||
// duas guardas. Sem elas aqui, conversa com dono humano recebia rascunho.
|
||
//
|
||
// ponytail: o caminho automático refaz as duas em `runAgentTurn` (que
|
||
// também serve follow-up e caso) — duas consultas a mais por turno, contra
|
||
// a IA de sempre e o Jev que elas poupam numa conversa calada.
|
||
if (await isLeadInHandoff(pool, job.organization_id, job.contact_id)) {
|
||
deps.log.info('turno pulado — lead em handoff humano (bot silenciado)', {
|
||
job_id: job.id,
|
||
conversation_id: payload.conversation_id,
|
||
});
|
||
return;
|
||
}
|
||
try {
|
||
const elegib = await decidirElegibilidadeDaConversa(pool, {
|
||
organizationId: job.organization_id,
|
||
conversationId: payload.conversation_id,
|
||
agora: deps.clock?.() ?? new Date(),
|
||
ttlMs: deps.knobs.allowlistTtlMs ?? ALLOWLIST_TTL_MS_PADRAO,
|
||
});
|
||
if (elegib !== null && !elegib.permite) {
|
||
deps.log.info('turno pulado — conversa não elegível para IA', {
|
||
job_id: job.id,
|
||
conversation_id: payload.conversation_id,
|
||
motivo: elegib.motivo,
|
||
});
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
// Degrada ABERTO, igual ao gêmeo de `runAgentTurn`: falha da consulta
|
||
// não pode calar um agente cuja conversa está liberada. Quem barra de
|
||
// verdade — handoff — já rodou acima e falha fechado.
|
||
deps.log.warn('checagem de elegibilidade falhou — seguindo', {
|
||
job_id: job.id,
|
||
error: (err instanceof Error ? err.message : String(err)).slice(0, 160),
|
||
});
|
||
}
|
||
const resolvedAgent = await resolveConversationTurn(pool, deps.llmCfg, {
|
||
tenantId: job.organization_id,
|
||
leadId: job.contact_id,
|
||
jobId: job.id,
|
||
conversationId: payload.conversation_id,
|
||
channelSessionId: payload.channel_session_id,
|
||
inbound: true,
|
||
}, { log: deps.log, jev: deps.jev });
|
||
const operationAgent = resolvedAgent.config;
|
||
if (operationAgent?.operationMode === 'assisted') {
|
||
// As travas do assistido (handoff e elegibilidade) já rodaram acima, antes
|
||
// de escolher o agente.
|
||
// #1648 — AS DETERMINÍSTICAS ANTES DO RASCUNHO. Este ramo devolvia
|
||
// antes das detecções de STOP/opt-out e de pedido de humano: o contato
|
||
// escrevia "SAIR" e nada era registrado, os follow-ups agendados seguiam
|
||
// vivos e "quero falar com uma pessoa" virava só um rascunho — risco de
|
||
// LGPD, porque o pedido de parar ficava dependendo de uma aprovação que
|
||
// podia nunca vir. `true` = turno silenciado (aviso enviado, silêncio
|
||
// durável, crons cancelados, item na Central) → sem rascunho algum.
|
||
if (
|
||
await deteccoesDeterministicasDoAssistido(pool, deps, {
|
||
job,
|
||
tenantId: job.organization_id,
|
||
conversationId: payload.conversation_id,
|
||
channelSessionId: payload.channel_session_id,
|
||
leadId: job.contact_id,
|
||
agent: operationAgent,
|
||
log: deps.log,
|
||
inboundMessageId: payload.inbound_message_id,
|
||
})
|
||
) {
|
||
return;
|
||
}
|
||
const { generateReplyDraft } = await import('./reply-drafts');
|
||
if (!job.contact_id) throw new Error('reply_without_contact');
|
||
await generateReplyDraft(pool, deps, {
|
||
organizationId: job.organization_id,
|
||
conversationId: payload.conversation_id,
|
||
contactId: job.contact_id,
|
||
channelId: payload.channel_session_id,
|
||
boundary: currentExecutionBoundary() ?? undefined,
|
||
agent: operationAgent,
|
||
});
|
||
return;
|
||
}
|
||
if (operationAgent?.pausedAt) return;
|
||
// UMA RESPOSTA POR MENSAGEM: um turno que rodou antes deste pode ter lido a
|
||
// mensagem que acordou este job e já respondido a ela — ver o cabeçalho de
|
||
// `turno-ja-respondido.ts`, com o caso medido. A anotação vem DEPOIS da
|
||
// pergunta e ANTES de `runAgentTurn` ler a conversa: é ela que deixa o
|
||
// próximo turno fazer a mesma pergunta a respeito deste.
|
||
const alvo = {
|
||
organizationId: job.organization_id,
|
||
contactId: job.contact_id,
|
||
conversationId: payload.conversation_id,
|
||
jobId: job.id,
|
||
};
|
||
if (await ultimaInboundJaRespondida(pool, alvo)) {
|
||
deps.log.info('turno pulado — outro turno já viu e respondeu a última mensagem do cliente', {
|
||
job_id: job.id,
|
||
conversation_id: payload.conversation_id,
|
||
inbound_message_id: payload.inbound_message_id,
|
||
});
|
||
return;
|
||
}
|
||
await anotarUltimaInboundVista(pool, alvo);
|
||
await runAgentTurn(deps, job, pool, ctx, {
|
||
resolvedAgent,
|
||
channelSessionId: payload.channel_session_id,
|
||
conversationId: payload.conversation_id,
|
||
inboundMessageId: payload.inbound_message_id,
|
||
buildOpening: ({
|
||
previous,
|
||
leadState,
|
||
context,
|
||
notesIndexBlock,
|
||
projeta,
|
||
entregues,
|
||
compromissosBlock,
|
||
currentInboundText,
|
||
}) =>
|
||
buildOpeningMessage(
|
||
previous,
|
||
leadState,
|
||
context,
|
||
notesIndexBlock,
|
||
projeta,
|
||
entregues,
|
||
compromissosBlock,
|
||
currentInboundText,
|
||
),
|
||
});
|
||
};
|
||
}
|