Skip to content

Latest commit

 

History

History
431 lines (322 loc) · 25.3 KB

File metadata and controls

431 lines (322 loc) · 25.3 KB

HANDOFF — Os três papéis do agente (Conversador · Operador · Segurança)

Documento vivo. Alimentado a cada avanço, cada bug encontrado, cada coisa deixada para trás. Toda afirmação declara o SHA curto de onde foi medida. Número sem SHA não compara.

Contrato: docs/specs/16-spec-tres-papeis-do-agente.md Doutrina: docs/doctrine/separacao-fala-e-operacao.md Branch: feat/tres-papeis-do-agente · Base: origin/main = 0a85d251 Worktree dedicado: /Users/rafaelmelgaco/DeskcommCRM-tres-papeis — a árvore principal foi trocada por outra sessão duas vezes no meio do trabalho, e o worktree acabou com a disputa. Ele não tem .env.local, e isso é uma proteção, não um detalhe (ver Passo 5 abaixo).


Estado da esteira

# passo estado prova
1 Gate de vazamento no before-send feito (veio da main) 30,0% / 0,0% medidos, 18 turnos
2 Contrato da declaração feito e167b362 16 testes · 5 sabotagens · test:db verde
3 Projeção do contexto feito (este commit) 18 testes, 3 contra turnos REAIS
4 Operador por evento feito 35c88346 12 testes · 3 sabotagens com contagem prevista · test:db verde
5 UI dos três papéis + Testar no caminho real feito 9b26ad76·2b85047e·1b9649c6·1e19ef4a 29 testes · e2e 7/7 pela tela · 4 sabotagens no Testar
6 Tirar tools de escrita do Conversador feito 16386d0e+91f31c52 21 testes · medido: 30,0% → 10,0% com ferramentas executadas

Linha de base medida (SHA 0a85d251, árvore limpa)

medida valor
Vazamento com prompt de OPERADOR 30,0% (3 em 10)
Vazamento com prompt de ATENDIMENTO 0,0% (0 em 8)
Tools no catálogo MCP 51 (30 leitura, 20 escrita, 1 handoff)
Tools nativas do engine 12
Teto de tools por agente 20 → até 32 num prompt só
Gates de before_send 10 (cadeia v6)
pnpm vitest run 2603 passed / 270 arquivos
pnpm lint 0 erros, 189 warnings

Passo 2 — a declaração do turno · e167b362

O que mudou. lead_checkpoints ganhou declaracao jsonb; a declaração viaja na chamada de fechamento que já existe (custo zero), não numa tool que o modelo poderia esquecer.

A decisão que carrega o desenho: undefined/NULL (não declarou) ≠ {nada_a_declarar:true} (avaliou e não havia nada). Um default otimista faria "esqueceu" parecer "não havia nada".

Consumidor imediato: buildHandoffSummary. Sem ele a declaração seria evento sem consumer.

Evidência: 16 testes · suíte 2603 · typecheck limpo · lint 0 erros / 189 warnings (mesmo número da main, medido com stash) · test:db verde (68 arquivos, 462 passed).

Sabotagem — 5 defeitos, 5 reprovações no teste certo:

sabotagem resultado
.default({}) no lugar do .optional() 2 failed ✅
declaração sai da instrução de fechamento 1 failed ✅
handoff para de ler a declaração 2 failed ✅
.strict() vira .passthrough() 1 failed ✅
vocabulário interno entra na instrução 1 failed ✅

Passo 3 — a projeção · este commit

O que mudou. lib/agent-engine/agent/projecao.ts: allowlist do que o Conversador vê. Ligada em três superfícies — abertura do ritual, releitura via get_lead_context, e retorno de search_knowledge.

O interruptor. Arma quando nenhuma ferramenta de catálogo entrou no turno — é exatamente onde os ids do contexto não têm uso (as MCP os recebem como argumento; as nativas resolvem pelo closure). Sem env novo, sem botão para o self-hoster errar. No passo 6 ela passa a valer sozinha.

Evidência: 18 testes · suíte 2621 passed / 271 arquivos · typecheck limpo · lint 0 erros. Três casos medem contra os turnos REAIS de gpt-5.6-terra versionados em evidence/, com controle positivo em cada um e falha declarada se a evidência sumir.

Achados desta etapa

🐛 search_knowledge vazava dois UUIDs por resultado — corrigido aqui. chunk_id e knowledge_source_id iam crus ao modelo em toda busca com RAG. O modelo não tem o que fazer com eles (nenhuma ferramenta os aceita como argumento); as citações são montadas pelo código. Era fonte silenciosa do UUID cru que a medição viu chegar à tela do cliente. Não estava previsto no plano — apareceu ao procurar consumidor para a projeção.

🐛 Bug que quase entrei, registrado porque o teste dele fica. Eu aplicava a tradução de erro ao corpo das mensagens. "Meu site tem um webhook quebrado" — frase legítima de quem vende integração — viraria "não consegui concluir essa verificação", e o agente responderia a uma pergunta que ninguém fez. A fala de quem está do outro lado é o único dado que nunca se corrige. Coberto por teste de regressão.

🔧 Cabeçalho do bloco de estado imprimia lead_state — nome de tabela no prompt, vazamento gratuito pela porta 2 sem nem precisar de uma ferramenta. Corrigido.

🐛 Bug MEU que entrou e a sabotagem pegou — a859a735. traduzirErroCru era aplicada a toda string do retorno de ferramenta. O detector considera "webhook" vazamento (com razão, na saída), e a base de conhecimento de qualquer empresa que venda integração fala webhook: o content do search_knowledge viraria "não consegui concluir essa verificação" linha após linha. A projeção destruiria o RAG do tenant para proteger contra um vazamento que o gate de saída já cobre.

É a mesma falha da fala do cliente, numa irmã que não se parece por fora: as duas nascem de aplicar num texto de terceiros um filtro desenhado para texto do sistema. Procurei a classe depois de pegar a primeira instância e não achei esta.

Como o teste passava sem medir: o detector pega UUID (categoria erro_cru), então os casos de evidência passavam pela tradução mesmo com a remoção de chave desligada — dois mecanismos redundantes, e eu atribuía o resultado ao errado. O sinal foi a sabotagem S2 derrubar 1 caso de 3. Depois da correção: S2 derruba 3 de 3.

Sabotagem do passo 3 — 8 defeitos, 8 reprovações no teste certo

sabotagem resultado
allowlist vira spread (campo novo passa) 2 failed ✅
chaveDeIdentificador desligada 3 failed ✅ (era 1 antes da correção)
tradução devolve texto cru (falha aberta) 1 failed ✅
interruptor arma sempre 1 failed ✅
tradução volta a pisar na fala do cliente 1 failed ✅
chaveDeErro aceita tudo (volta o bug do RAG) 1 failed ✅
chaveDeErro rejeita tudo (desarma a defesa) 2 failed ✅

Passo 4 — o Operador nasce · 35c88346

O que mudou. job_queue aceita operator_turn; o job é enfileirado pelo runtime ao fim do turno, logo depois de o checkpoint existir, com sourceEventId = job do Conversador (retry não gera um segundo Operador). Handler em operator-turn.ts, registrado no worker. ai_agent_versions ganhou operator_enabled (default false) e operator_model (null = herda).

Onde o passo 2 se paga. O curto-circuito usa a distinção que construímos lá:

declaração decisão por quê
nada_a_declarar: true não chama modelo quem avaliou estava lá, com todo o contexto
ausente (null) roda ninguém avaliou — é aí que promessa fica órfã

Há um teste afirmando que os dois estados levam a decisões opostas. Se alguém colapsar a distinção, ele vermelha.

Evidência: 7 testes de decisão + 5 invariantes de schema · suíte 2630 passed / 272 arquivos · test:db verde (69 arquivos, 467 passed) · typecheck limpo · lint 0 erros / 189 warnings.

Sabotagem do passo 4 — com a contagem PREVISTA antes de rodar

sabotagem previsão resultado
ausente tratado como vazio ≥2 2 failed
papel desligado passa a rodar 2 2 failed
infere "vazio" por listas em vez da afirmação 1 1 failed

Prever a contagem virou regra depois do passo 3, onde uma sabotagem derrubou 1 caso quando devia derrubar 3 — e o verde dos outros 2 vinha de um mecanismo redundante que era, ele próprio, um bug.

Dois erros meus que as CATRACAS DO REPO pegaram — não eu

  1. Segundo bloco de job_queue_kind_check no baseline. Reconstruir a mesma constraint em N blocos quebra o update.sh de todo clone com vocabulário posterior. Eu tinha aplicado essa lição corretamente ao agent_inbox_items minutos antes, e a irmã passou batido — o padrão pego numa ocorrência dá álibi às outras. Pego por baseline-constraint-reconstruida.test.ts.
  2. Kind novo divergindo entre banco e TypeScript — pego por vocabulario-banco-x-typescript.test.ts.

Os dois viraram verde depois de corrigidos, com run limpo. Registro aqui porque a saída limpa sem esta nota creditaria ao meu rigor o que foi mérito da catraca.


Passo 5 (parcial) — a mão do Operador e a tela · 9b26ad76, 2b85047e

Fatia 1 — o Operador ganha mão.operator_tool_ids (coluna própria, migration 0112), monta o toolset pela ponte MCP e roda turno de modelo com briefing e custo próprios (purpose: 'operator_turn' — sem isso "quanto custa ligar o papel?" não teria resposta).

Sem canal, estruturalmente: send_message é nativa do engine e não existe no catálogo, então não há id que a ligue; crm_send_whatsapp_message está em BLOCKED_TOOL_IDS, agora exportado para ser asserível — garantia que nenhum teste consegue ler é garantia que ninguém percebe quando some.

Fatia 2 — a tela. Navegação por papel dentro do mesmo form (um rascunho, um save). A régua é dizer a consequência: com o papel desligado, a tela explica o que continua acontecendo (o básico é registrado sozinho) e o que para. Sem a primeira frase o usuário conclui que desligar deixa o sistema cego, e liga por medo em vez de escolha.

Evidência: 16 testes novos (8 de motor, 8 de componente) · suíte unit 1459 passed / 154 arquivos · test:db verde (69 arquivos, 467 passed) · typecheck limpo · lint 0 erros · 4 sabotagens na tela, 4 reprovações, contagem prevista em cada uma.

🚨 BLOQUEIO: a prova de tela não pôde ser feita nesta máquina

.env.local aponta NEXT_PUBLIC_SUPABASE_URL para …porysaiysiztn.supabase.co — a nuvem, não o Supabase local (que está de pé em 127.0.0.1:54321). O playwright.config.ts sobe o app com next start, que carrega .env.local.

Consequência: rodar pnpm test:e2e nesta máquina, hoje, cria agentes, versões e conversas de teste no banco de produção. Não rodei.

Isto não é específico deste épico: vale para qualquer sessão que rode e2e nesta máquina agora.

Saídas possíveis (decisão do Rafael, é config dele):

  1. .env.e2e apontando para o local, carregado pelo webServer do Playwright — conserta para todo mundo e é o que a doutrina de QA Visual pressupõe;
  2. apontar .env.local para o local enquanto se testa (manual, esquecível — foi o que já mordeu antes, ver feedback_env_local_aponta_para_remoto);
  3. rodar a prova numa VPS descartável.

Enquanto isso, o que está provado da tela é comportamento de componente, não jornada de usuário. São coisas diferentes e a diferença está declarada.


🔴 A suíte E2E escrevia no banco de PRODUÇÃO · 783ee85b

Descoberto ao destravar a prova de tela. Não é específico deste épico.

.env.local de um checkout de trabalho aponta para a nuvem, e o Playwright sobe o app com next start, que o carrega. A suíte criava organizações, usuários e agentes de teste no banco real — passando verde. A org e2e-test-org está na produção desde 2026-04-29: não chegou lá por acidente de uma sessão, chegou porque era o comportamento normal.

Isolar o webServer não bastava — e essa foi a descoberta que importa. Os scripts de seed leem .env.local direto do disco, ignorando process.env, e as specs os chamam sozinhas no meio do teste. O sintoma que denunciou: o factor TOTP em .e2e-creds.json não existia no banco local, porque tinha sido criado na nuvem. 93 arquivos têm esse padrão.

As três camadas do conserto

# camada o que impede
1 .env.e2e + injeção no webServer, com guard que recusa se o arquivo faltar o servidor falar com a nuvem
2 pnpm e2e:build as NEXT_PUBLIC_* de produção ficarem dentro do bundle do browser
3 scripts/lib/env-de-teste.tsprocess.env vence, e todo seed anuncia o destino os seeds escaparem do isolamento

A camada 2 existe porque node --env-file não serve: o next build cria Workers e o Node recusa propagar a flag (ERR_WORKER_INVALID_EXEC_ARGV). O script prova as duas direções — host de produção ausente do bundle e host local presente. Sem o controle positivo, "não achei produção" pode ser apenas um grep que não acha nada.

Os 16 seeds migrados · 3159e0db

Todos os scripts/seed-e2e-*.ts usam credenciaisSupabaseDeTeste() e anunciam o destino. Dois já tinham a precedência certa e só ganharam o anúncio (capacidades lia o arquivo como fallback, invite já usava process.env puro) — mas o capacidades estourava com ENOENT num worktree sem .env.local, justo onde a ausência é a proteção.

Gate: tests/unit/seed-nao-le-env-local-do-disco.test.ts. Sem ele, o 17º seed nasce copiando o mais próximo. Escrever em produção não é reparável por code review depois do fato.

A primeira regex do gate não media nada. Era /readFileSync\s*\(\s*(?:path\.join\([^)]*)?…\.env\.local/, e o [^)]* parava no ) de process.cwd() — no meio de path.join(process.cwd(), ".env.local"). A alternativa nunca casava. Descoberto por sabotagem: reintroduzir a leitura num seed não vermelhou nada. A versão atual procura o literal em vez de modelar a sintaxe — regex que entende estrutura erra calada.

Prova: os 16 seeds executados de verdade, 16/16 anunciando escrevendo em LOCAL. Typecheck sozinho não bastava: ele já deixou passar um env is not defined nesta mesma série.

A prova pela TELA · 1b9649c6

tests/e2e/agente-papeis-operador.spec.ts7/7 passed em 1,8 min contra o Supabase local.

O caso central é salvar → RECARREGAR → conferir. Teste de componente mede o componente; entre a escolha do usuário e o banco existem o formulário, a server action, o Zod, o VERSION_COLUMNS de seis arquivos e o SELECT que relê — cada um pode perder um campo sem quebrar unidade nenhuma.

Sabotagem que prova o teste: removi operator_enabled do SELECT de um dos seis arquivos (page.tsx) — o defeito exato que já aconteceu com cases_enabled e que eu repeti nesta série. O spec reprovou, e a mensagem apontou o culpado:

o papel voltou desligado depois do refresh — algum ponto do caminho não carrega operator_enabled (ver agent-version-columns-drift.test.ts)

Dois achados ao escrever o spec

🐛 default_agent_id do seed é um rag_bot, e a tela de papéis é do mcp_agent. Apontar para ele fazia o spec falhar como se a aba não existisse. O spec passou a semear a própria precondição — depender de um mcp_agent que outra spec deixou é depender da ordem alfabética dos arquivos.

🐛 Um login por teste estourava o teto do produto. 7 casos = 7 logins, contra um teto de 60/IP a cada 5 min — o mesmo que protege a conta de um cliente real. A bateria caía no 3º caso e a falha aparecia como "campo de MFA não apareceu", parecendo defeito de produto onde havia limite de ambiente. Agora é um login numa página compartilhada: 7/7 em 1,8 min, contra 6/7 em 7,5 min.

O botão Testar para de mentir · 1e19ef4a

Ele mostrava uma resposta que nenhum gate examinou — a rota chama o runtime @deprecated, que não importa runBeforeSend, e a cadeia inteira está ausente do build do app Next.

O conserto não finge cobertura. Seis dos dez gates dependem de estado que só existe no turno real (contadores de pacing, janela de cópias, carimbo da última inbound, ledger, base legal). Fabricar esse estado daria um veredito inventado — pior que veredito nenhum, porque tem aparência de prova. Então a avaliação cobre o que é decidível só com o texto (o gate de vocabulário interno, o mais caro pela medição) e declara os outros nove na tela, com o motivo de cada um.

Os motivos são escritos para o dono do negócio: "depende de quando o contato falou com você pela última vez", não "depende do send_ledger". Há teste varrendo jargão nessa lista — repetir na tela de configuração o vocabulário que a spec 16 existe para matar seria irônico.

A lista é amarrada a BEFORE_SEND_GATES: acrescentar um gate à cadeia vermelha o teste e força decidir de que lado ele cai. O caminho do stub também avalia — um caminho sem avaliação voltaria a ser o verde que não olhou para nada, só que mais difícil de notar.

sabotagem previsão resultado
passou vira sempre true 1 1 failed
some a lista de não-avaliados ≥3 3 failed
jargão entra no motivo mostrado ao usuário 1 1 failed
um gate some da lista, como se fosse avaliado 1 1 failed

O ganho estrutural do worktree

Este worktree não tem .env.local. Um script que o leia do disco falha alto (ENOENT) em vez de escrever na nuvem. O isolamento deixa de depender de disciplina e passa a depender da ausência do arquivo — que é o tipo de garantia que não esquece.

Prova: agente-novo-e-uso 5/5 passed contra o banco LOCAL (era 0/5, por MFA — o factor do banco não batia com o do arquivo, justamente porque o seed ia para a nuvem).


Passo 6 — a cura · 16386d0e

O gate é rede: barra na saída e ensina. A cura é o Conversador nunca ter visto o vocabulário — o modelo não repete o nome de uma ferramenta que não está no contexto dele.

A regra que impede o buraco: a capacidade só muda de dono quando o novo dono existe. A nativa sai do Conversador apenas se o Operador estiver ligado E tiver o equivalente marcado. Ligar o papel e esquecer de marcar crm_move_lead_stage tiraria o avanço do funil sem dar a ninguém — e o funil pararia em silêncio, que é o modo de falha que este épico combate.

A instrução sai do prompt junto. Remover a ferramenta e manter a linha "Marque-o com update_lead_state" seria o pior dos dois mundos: o modelo chamaria o que não existe e o nome continuaria no contexto — a cura não teria acontecido. Há teste só para isso.

O que saiu, e o que ficou (medido contra o catálogo real)

nativa destino por quê
update_lead_state ✅ sai crm_move_lead_stage / crm_update_lead cobrem
schedule_followup ✅ sai crm_schedule_followup cobre
save_lead_note ❌ fica sem equivalente no catálogo
open_human_case ❌ fica sem equivalente no catálogo
provide_case_update ❌ fica sem equivalente no catálogo
request_human_handoff ❌ fica existe no catálogo mas está em BLOCKED_TOOL_IDS; e é decisão da CONVERSA, com efeito imediato

A tabela de equivalência é verificada nos dois sentidos: toda nativa listada existe em AGENT_TOOL_DEFS, todo equivalente existe em TOOL_CATALOG. Um typo em qualquer lado faria o corte nunca acontecer, silenciosamente.

Zero mudança para quem não ligou o papeloperator_enabled nasce false.

🔴 O sinal de sucesso do passo 6 foi medido — e NÃO se cumpriu

Relatório: RELATORIO-passo6.md. Mesmo modelo da linha de base (gpt-5.6-terra), 10 cenários × 3 configurações.

configuração taxa
A · CONTROLE (linha de base replicada) 10,0%
B · passo 6 como entregue 10,0%
C · cura completa (operação sai) 10,0%

As três iguais, mesmo cenário vazando. E em C — sem nenhuma ferramenta de webhook no contexto — o modelo ainda disse webhook: parte do vocabulário vem do MODELO, não do contexto. Ausência de ferramenta reduz superfície; não elimina vazamento.

O controle não reproduz os 30%, e a causa está identificada: o instrumento não executa as ferramentas, então não exercita a porta 3 (dado retornado) — que era 2 dos 3 vazamentos originais. Ele mede a porta 1/2, e nela achou exatamente 1 de 10, coerente com o único vazamento por nome da linha de base. Calibração parcial e explicada, não aleatória.

O que isso confirma: o prompt é a variável dominante (30% → 0% na linha de base foi troca de prompt, mesmas ferramentas). O valor do épico não é "zero por ausência" — é o dono do negócio deixar de precisar escrever "atenda E organize" no prompt do Conversador, porque agora existe um papel para isso. O gate segue como rede para o resíduo que vem do próprio modelo.

O instrumento reportou 0,0% nas três configurações na primeira execução — parecia sucesso perfeito. As 30 chamadas tinham voltado HTTP 400, os textos vieram vazios, e o detector não acha nada em texto vazio. O erro estava capturado por linha; o resumo só imprimia a taxa. Corrigido: o script recusa calcular taxa quando há turnos que não rodaram.

⚠️ O que ainda não foi medido

A spec define o sinal como "o vazamento medido em 30% vai a zero por ausência, não por filtro". Isso exige re-rodar a medição (18 turnos, gpt-5.6-terra) com o Operador ligado e comparar. Não foi feito: a medição original precisa de chave de LLM com crédito e de um turno real com worker.

Até lá, o que está provado é o mecanismo (a ferramenta e o nome somem do contexto), não o efeito (a taxa cair). São coisas diferentes, e a diferença está declarada.


Deixado para trás (declarado, não escondido)

# o quê por quê ficou onde fecha
1 Conteúdo do bloco de estado (stage: 'qualifying') segue cru update_lead_state precisa dele para marcar o próximo estágio passo 6, junto com a ferramenta
2 Retorno de tool MCP não é projetado remover ids quebraria a operação; hoje cobre o gate de saída passo 6 (Conversador sem elas)
3 projeta é opcional no tipo de buildOpening tests/invariants/** é congelado por hook; obrigatório forçaria editar invariante quando houver flip legítimo
4 Botão Testar não exercita guardrail nenhum runAgent deprecated não importa runBeforeSend passo 5
5 2ª camada do fail-safe de vazamento nunca exercitada o modelo consertou de primeira na observação
6 Inbound sem turno próprio: 5 mensagens → 4 jobs, nenhum deduped fora do escopo da medição que o achou a investigar
7 Tudo medido só em gpt-5.6-terra chave Anthropic da máquina sem crédito
8 O Operador ainda não tem MÃO o passo 4 entrega o CANAL (job, disparo, decisão, config); as ferramentas de escrita entram junto com a UI que as configura passo 5
9 Nenhum turno de produção observado com worker real a projeção e o Operador estão provados por unit + payload real, não por execução ponta a ponta passo 5, junto com a prova de tela
10 Botão Testar sem guardrail feito — avalia o texto e declara os 9 gates que não consegue avaliar
11 Jornada de usuário na tela nova feitoagente-papeis-operador.spec.ts, 7/7
12 Sondas lendo .env.local do disco migradas — 0 arquivos leem do disco; gate cobre scripts/ + tests/ + tests/e2e/ (188 casos)
14 Taxa total (com ferramentas EXECUTADAS) — o que calibraria o controle exige MCP real contra dados de verdade: worker + banco quando houver ambiente
15 3 nativas sem equivalente no catálogo (save_lead_note, open_human_case, provide_case_update) expor equivalentes é decisão de produto, não de refactor quando o catálogo crescer
13 Fixtures de e2e na produção removidas em 2026-08-06 — 2 orgs, 5 usuários, 57 contatos, 144 mensagens, 56 leads, 18 agentes
16 🐛 Não é possível deletar uma organização o trigger de audit insere em api_audit_log referenciando a org que o cascade acabou de apagar → FK violation a investigar
17 🐛 Não é possível deletar um agente que é dono de lead o SET NULL em crm_leads.owner_agent_id viola crm_leads_owner_kind_coherence (owner_kind='ai' sem agente) a investigar

Regras de trabalho desta frente

  1. Testar a cada avanço, não no fim. Avanço sem teste não é avanço.
  2. Verde de primeira não prova nada — sabotar cada afirmação e confirmar a reprovação.
  3. Evidência real acima de fixture. Fixture mede a imaginação de quem a escreveu — que é a que já falhou, senão o defeito não existiria.
  4. Commitar antes de sabotar. git checkout <arquivo> leva junto o trabalho não commitado (aconteceu, custou 4 edições).
  5. Não driblar catraca do repo. O hook de invariantes bloqueou um commit; a saída foi mudar o desenho, não exportar a variável de escape.