refactor(mcp): remove a description morta do catálogo — 51 cópias que ninguém lia

`lib/mcp/tools/catalogo/*.ts` declarava `description` em 51 capacidades; o tipo a
documentava como "Texto tecnico entregue ao MODELO" e sete cabeçalhos repetiam
"`description` fala com o modelo". Nenhum consumidor lia esse campo: a ponte do
turno (`lib/ai/runtime/tools.ts`) monta `def.description`, do HANDLER, e a rota
`/api/v1/mcp/tools` também. As duas fontes divergiam em 48 das 51.

## Por que remover, e não sincronizar

Fonte única mudaria o que 48 tools dizem ao modelo — risco alto, ganho zero. Um
gate de paridade obrigaria manter dois textos sincronizados para sempre, com a
duplicata seguindo lá para ser editada por engano. Remover mata a armadilha na
raiz: não dá para editar o lugar errado se o lugar não existe.

E a remoção SE AUTO-VERIFICA. Tirei o campo do tipo e o `tsc` apontou cada
leitura — prova mais forte que grep.

## O que o typecheck achou: a dívida não era teórica

Um leitor, e o pior possível:
`evidence/ia-360-w4/medicao-vazamento/remedir-com-operador.ts`, o script que mede
vazamento de vocabulário do agente. A função se chama `descreverFerramentas` e o
comentário diz "A ferramenta como o modelo a vê: nome + descrição, que é o que
pode vazar" — e lia `TOOL_CATALOG.description`, exatamente o texto que o modelo
NÃO vê.

NÃO MEDIDO: se isso muda o resultado daquela medição. O `name` é idêntico nas
duas fontes e é o vetor principal de vazamento, então o efeito pode ser nulo,
mas não rodei. Corrigi a fonte para `allTools` e deixei a ressalva no script. Não
reabri a medição arquivada de outra branch.

## O buraco que a remoção expôs

`tests/unit/catalogo-servido.test.ts` testava a junção com FIXTURES
(`description: "faz algo"`), nunca com o catálogo real — nenhum gate garantia que
uma capacidade servida tem descrição. Esvaziar a de um handler passaria calado: a
tela sem explicação e o modelo com uma ferramenta sem contrato.

Caso novo: toda capacidade servida tem descrição não-vazia E ela é IDÊNTICA à do
handler. A segunda metade é a que importa — se reaparecer uma cópia no catálogo e
a junção preferi-la, reprova.

Sabotagens: `description` de um handler vira "" → 1 → 1 (a primeira tentativa não
sabotou nada: escrevi `description: "" ||`, e `"" || "texto"` devolve o texto —
instrumento quebrado, não gate fraco); junção servindo outro texto → 1 → 2.

## Correção de um número que publiquei

A mensagem do commit c56416aa diz "1819 unitários". O real naquele SHA é 1818,
medido depois com `git stash` e a árvore limpa em c56416aa. Rodei a suíte com a
árvore ligeiramente diferente da commitada (antes do `rm` do protótipo e do stage
final) e publiquei como se fosse do commit. Régua daqui em diante: número que sai
em artefato público é medido DEPOIS do stage.

## Saldo

130 linhas removidas, 63 acrescentadas, 9 arquivos. Os 7 cabeçalhos falsos foram
reescritos com o que é verdade e POR QUE o campo não existe mais — para ninguém
"completar" o catálogo de volta. O BRIEFING-ia-360.md acompanhou.

Provado na tela: `/app/ai/agents/<mcp_agent>`, Supabase local (HTML de /login com
2× `127.0.0.1:54321`, 0× `*.supabase.co`), a capacidade segue com rótulo,
explicação, categoria e área — 6/6, zero erro de console.

typecheck 0 · lint 0 errors · 1819 unitários (1818 + 1 caso novo)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkS3mzwtXughmjVC5FCoNo
This commit is contained in:
Rafael Melgaço
2026-08-07 12:48:53 -03:00
co-authored by Claude Opus 5
parent c56416aad9
commit 02d9aceacf
15 changed files with 179 additions and 135 deletions
+4 -1
View File
@@ -117,7 +117,10 @@ export type ToolBundle = "atender" | "vender" | "reter" | "escalar" | "organizar
export interface McpToolCatalogEntry {
name: string; // CONTRATO DE WIRE — imutável depois de publicado
category: McpToolCategory; // dirige requiresScope/requiresRole — não é rótulo
description: string; // técnica, vai para o MODELO
// `description` VIVIA AQUI e foi REMOVIDA em 2026-08-07: nenhum consumidor a
// lia (a ponte do turno monta a do HANDLER) e 48 das 51 divergiam do que o
// modelo recebia de verdade. O texto que vai ao modelo é a `description` de
// `lib/mcp/tools/<dominio>.ts`, e não há cópia dele neste catálogo.
// camada de apresentação (pilar 3) — vai para o HUMANO:
rotulo: string; // "Mover lead de etapa" — verbo no infinitivo, pt-BR
+77 -1
View File
@@ -711,9 +711,85 @@ container → 1 → **1**.
| 2 | Crases dentro de template literal na sonda | `tsc` reprovou — mesmo erro que já cometi com SQL em template literal |
| 3 | **Duas imagens versionadas sem citação** no commit `3227bf81` | O gate `evidencia-citada` reprovou. O pre-commit não roda a suíte, então aquele commit deixou o CI vermelho e eu não vi |
### Correção de um número que publiquei
A mensagem do commit `c56416aa` diz **1819 unitários**. O real naquele SHA é
**1818** — medido depois, com `git stash` e a árvore limpa em `c56416aa`.
A causa não é aritmética: rodei a suíte com a árvore **ligeiramente diferente**
da que foi commitada (antes do `rm` do protótipo e do stage final) e publiquei o
número como se fosse do commit. Medir contra árvore em movimento é medir contra
nada, e o erro passou porque o número vinha cercado de material medido com
rigor. Régua para a próxima: número que sai num artefato público (commit, PR,
handoff) é medido **depois** do stage, não antes.
---
## A `description` morta do catálogo — resolvida por remoção (2026-08-07)
A dívida declarada duas seções acima virou trabalho. `lib/mcp/tools/catalogo/*.ts`
declarava `description` em **51 capacidades**, o tipo a documentava como
*"Texto tecnico entregue ao MODELO"*, os cabeçalhos de 7 arquivos repetiam
*"`description` fala com o modelo"* — e **ninguém lia esse campo**.
### Por que remover, e não sincronizar
| caminho | por que não |
|---|---|
| fonte única (runtime passa a ler o catálogo) | mudaria o que **48 tools** dizem ao modelo. Risco alto, ganho zero |
| gate de paridade com a dívida congelada | obriga manter dois textos sincronizados **para sempre**; a duplicata continua existindo para ser editada por engano |
| **remover o campo** | mata a armadilha na raiz — não dá para editar o lugar errado se o lugar não existe |
E a remoção **se auto-verifica**: tirei o campo do tipo e o `tsc` apontou cada
leitura. Prova mais forte que qualquer grep.
### O que o typecheck encontrou — a dívida não era teórica
Um leitor, e o pior possível:
`evidence/ia-360-w4/medicao-vazamento/remedir-com-operador.ts`, o script que
mede vazamento de vocabulário do agente. A função dele se chama
`descreverFerramentas` e o comentário diz **"A ferramenta como o modelo a vê:
nome + descrição, que é o que pode vazar"** — e ele lia
`TOOL_CATALOG.description`, exatamente o texto que o modelo **não** vê.
**O que NÃO medi:** se isso muda o resultado daquela medição. O `name` da tool é
idêntico nas duas fontes e é o vetor principal de vazamento, então o efeito pode
ser nulo — mas não rodei. Corrigi a fonte (`allTools`) para a próxima rodada ler
o que vai ao modelo, e deixei a ressalva escrita no próprio script. Não reabri a
medição arquivada de outra branch.
### O buraco que a remoção expôs
`tests/unit/catalogo-servido.test.ts` testava a junção com **fixtures**
(`description: "faz algo"`), nunca com o catálogo real. Ou seja: **nenhum gate
garantia que uma capacidade servida tem descrição.** Esvaziar a `description` de
um handler passaria calado — a tela mostraria a capacidade sem explicação e o
modelo receberia uma ferramenta sem contrato, dois silêncios de uma vez.
Caso novo: toda capacidade servida tem descrição não-vazia **e ela é idêntica à
do handler**. A segunda metade é a que importa: se um dia reaparecer uma cópia no
catálogo e a junção preferi-la, o gate reprova.
| Sabotagem | Previsão | Resultado | |
|---|---|---|---|
| `description` de um handler vira `""` | 1 | **1** ✅ | primeira tentativa não sabotou nada: escrevi `description: "" \|\|`, e `"" \|\| "texto"` devolve o texto — instrumento quebrado, não gate fraco |
| a junção passa a servir outro texto | 1 | **2** | reprovou o caso novo e mais um |
### Saldo
130 linhas removidas contra 63 acrescentadas em 9 arquivos. Os 7 cabeçalhos que
afirmavam a falsidade foram reescritos com o que é verdade e **por que** o campo
não existe mais — para ninguém "completar" o catálogo de volta. O
`BRIEFING-ia-360.md`, que também declarava o campo como "vai para o MODELO",
acompanhou.
`typecheck 0` · `lint 0 errors` · **1819 unitários** (1818 no `c56416aa` + 1
caso novo — desta vez medido com a árvore parada).
### Pendente
- **A `description` morta do catálogo** — decisão de desenho (48 de 51 divergem).
- Nada desta dívida. As pendências vivas são as do topo do arquivo
(`database.types.ts`, mapa de arquitetura) e as duas issues pré-existentes.
---
@@ -45,7 +45,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { detectarVazamentoInterno } from '@/lib/agent-engine/guardrails/vazamento-interno';
import { TOOL_CATALOG } from '@/lib/mcp/tools/catalog';
import { allTools } from '@/lib/mcp/tools';
import { AGENT_TOOL_DEFS } from '@/lib/agent-engine/agent/inbound-turn';
import { capacidadesEntreguesAoOperador } from '@/lib/agent-engine/agent/entrega-de-capacidade';
@@ -119,7 +119,16 @@ function cenariosDaLinhaDeBase(): Cenario[] {
/** A ferramenta como o modelo a vê: nome + descrição, que é o que pode vazar. */
function descreverFerramentas(nativas: string[], catalogo: string[]): Array<Record<string, unknown>> {
const defs = AGENT_TOOL_DEFS as Record<string, { description: string }>;
const doCatalogo = new Map(TOOL_CATALOG.map((t) => [t.name, t.description]));
// A descrição vem do HANDLER, não do catálogo. Esta linha lia
// `TOOL_CATALOG.description` — um campo que NENHUM consumidor lê: a ponte do
// turno (`lib/ai/runtime/tools.ts`) monta `def.description`, do handler, e as
// duas divergiam em 48 das 51 capacidades. O comentário acima diz "como o
// modelo a vê", e era exatamente o texto que o modelo NÃO vê.
//
// Não reabri a medição arquivada: o nome da tool, que é o vetor principal de
// vazamento, é idêntico nas duas fontes, então o efeito no resultado NÃO foi
// medido. Corrijo a fonte para que a próxima rodada leia o que vai ao modelo.
const doCatalogo = new Map(allTools.map((t) => [t.name, t.description]));
return [
...nativas
.filter((n) => defs[n] !== undefined)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 157 KiB

+9 -10
View File
@@ -1,8 +1,15 @@
/**
* Capacidades de ATENDIMENTO — cliente, conversa, mensagem.
*
* `description` fala com o modelo; `rotulo`/`explicacao`/`oQueToca` falam com o
* humano que configura o agente. Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
* ESTE ARQUIVO FALA COM O HUMANO que configura o agente — `rotulo`,
* `explicacao` e `oQueToca`. O texto que vai ao MODELO é a `description` do
* HANDLER (`lib/mcp/tools/<dominio>.ts`), e ela NÃO tem cópia aqui: até
* 2026-08-07 tinha, ninguém lia essa cópia, e 48 das 51 divergiam do que o
* modelo realmente recebia. O campo foi removido em vez de sincronizado —
* duplicata que ninguém lê não é documentação, é armadilha: um script de
* medição de vazamento chegou a montar o prompt com o texto errado, sob um
* comentário dizendo "a ferramenta como o modelo a vê".
* Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
*/
import { declararTools } from "./tipos";
@@ -10,7 +17,6 @@ export const TOOLS_ATENDIMENTO = declararTools([
{
name: "crm_search_contacts",
category: "read",
description: "Busca contatos por nome/telefone/email",
rotulo: "Procurar cliente",
explicacao:
"Encontra um cliente pelo nome, telefone ou e-mail, para o agente saber com quem está falando antes de responder.",
@@ -21,7 +27,6 @@ export const TOOLS_ATENDIMENTO = declararTools([
{
name: "crm_get_contact",
category: "read",
description: "Detalhe de um contato",
rotulo: "Ver ficha do cliente",
explicacao:
"Abre a ficha completa de um cliente: dados de contato, histórico e por onde ele chegou até a empresa.",
@@ -32,8 +37,6 @@ export const TOOLS_ATENDIMENTO = declararTools([
{
name: "crm_list_conversations",
category: "read",
description:
"Lista conversas (com assignee_kind, assigned_to_user_name, tags, queue_position)",
rotulo: "Listar conversas",
explicacao:
"Mostra as conversas em andamento, quem está cuidando de cada uma e a posição de cada cliente na fila de espera.",
@@ -44,8 +47,6 @@ export const TOOLS_ATENDIMENTO = declararTools([
{
name: "crm_get_conversation",
category: "read",
description:
"Detalhe de conversa (com assignee_kind, assigned_to_user_name, tags, queue_position)",
rotulo: "Ver uma conversa",
explicacao:
"Abre os detalhes de uma conversa: quem está atendendo, marcadores aplicados e há quanto tempo o cliente espera.",
@@ -56,7 +57,6 @@ export const TOOLS_ATENDIMENTO = declararTools([
{
name: "crm_get_conversation_history",
category: "read",
description: "Historico de mensagens de uma conversa",
rotulo: "Ler o histórico da conversa",
explicacao:
"Lê as mensagens já trocadas com o cliente, para o agente responder sem pedir que ele repita o que já contou.",
@@ -67,7 +67,6 @@ export const TOOLS_ATENDIMENTO = declararTools([
{
name: "crm_send_whatsapp_message",
category: "write",
description: "Envia mensagem WhatsApp",
rotulo: "Enviar mensagem no WhatsApp",
explicacao:
"Envia uma mensagem de WhatsApp para o cliente. Ele recebe de verdade, no celular dele, e não dá para desfazer.",
-9
View File
@@ -10,9 +10,6 @@ export const TOOLS_COMERCIO = declararTools([
{
name: "crm_list_contact_orders",
category: "read",
description:
"Lista os pedidos de um contato, do mais recente para o mais antigo, com status, valor, " +
"forma de pagamento, situação de entrega e código de rastreio.",
rotulo: "Ver as compras do cliente",
explicacao:
"Mostra o que este cliente já comprou, quanto pagou e como está a entrega, para o assistente não prometer prazo no escuro nem repetir uma oferta já aceita.",
@@ -23,9 +20,6 @@ export const TOOLS_COMERCIO = declararTools([
{
name: "crm_search_products",
category: "read",
description:
"Busca produtos do catálogo da loja por parte do nome. Devolve preço, quantidade " +
"disponível e link.",
rotulo: "Procurar produto na loja",
explicacao:
"Procura um produto pelo nome e devolve preço e quantidade em estoque, para o assistente responder com o dado da loja em vez de estimar.",
@@ -36,9 +30,6 @@ export const TOOLS_COMERCIO = declararTools([
{
name: "crm_list_privacy_requests",
category: "read",
description:
"Lista pedidos de privacidade (LGPD) da organização, com tipo, situação, chegada e prazo. " +
"NÃO executa nada: é leitura.",
rotulo: "Ver pedidos de privacidade",
explicacao:
"Mostra quem pediu para exportar ou apagar os próprios dados e qual o prazo, para o assistente parar de insistir com quem pediu para sair.",
+9 -30
View File
@@ -10,8 +10,15 @@
* As seis capacidades abaixo fecham as duas direções: o agente participa do
* atendimento humano em vez de terminar nele.
*
* `description` fala com o modelo; `rotulo`/`explicacao`/`oQueToca` falam com o
* dono da clínica que configura o agente. Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
* ESTE ARQUIVO FALA COM O HUMANO que configura o agente — `rotulo`,
* `explicacao` e `oQueToca`. O texto que vai ao MODELO é a `description` do
* HANDLER (`lib/mcp/tools/<dominio>.ts`), e ela NÃO tem cópia aqui: até
* 2026-08-07 tinha, ninguém lia essa cópia, e 48 das 51 divergiam do que o
* modelo realmente recebia. O campo foi removido em vez de sincronizado —
* duplicata que ninguém lê não é documentação, é armadilha: um script de
* medição de vazamento chegou a montar o prompt com o texto errado, sob um
* comentário dizendo "a ferramenta como o modelo a vê".
* Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
*/
import { declararTools } from "./tipos";
@@ -19,12 +26,6 @@ export const TOOLS_ESCALACAO = declararTools([
{
name: "crm_list_available_attendants",
category: "read",
description:
"Lista os atendentes da org com disponibilidade, capacidade, carga atual e se podem " +
"receber uma conversa AGORA (mesmo predicado do worker de roteamento: disponível ∧ com " +
"folga ∧ dentro do horário). Consulta livre: o caminho de escalação JÁ devolve a " +
"expectativa sozinho, então isto serve para planejar, não para cumprir obrigação. " +
"Não devolve e-mail nem telefone.",
rotulo: "Ver quem pode assumir agora",
explicacao:
"Mostra quais pessoas da equipe estão em atendimento neste momento, quantas conversas cada uma já tem e quem ainda tem espaço para receber mais uma.",
@@ -35,10 +36,6 @@ export const TOOLS_ESCALACAO = declararTools([
{
name: "crm_list_human_cases",
category: "read",
description:
"Lista os casos humanos da org por estado (abertos = awaiting_human|awaiting_lead; " +
"fechados = resolved|escalated|cancelled), com título, bloqueio, estado e a conversa de " +
"origem. Use para saber o que já foi pedido a uma pessoa antes de pedir de novo.",
rotulo: "Ver os chamados em aberto",
explicacao:
"Lista os assuntos que já foram passados para uma pessoa resolver, com o estado de cada um, para o agente não pedir duas vezes a mesma coisa.",
@@ -49,10 +46,6 @@ export const TOOLS_ESCALACAO = declararTools([
{
name: "crm_get_human_case",
category: "read",
description:
"Detalhe de UM caso humano: título, resumo, bloqueio, estado e a timeline completa de " +
"eventos, incluindo o que o humano decidiu (human_action + texto) e o que o agente " +
"registrou. Devolve também o bloco de continuidade pronto para retomar a conversa.",
rotulo: "Ler um chamado e o que a pessoa decidiu",
explicacao:
"Abre um chamado e mostra tudo que aconteceu nele, inclusive a decisão que a pessoa tomou e o texto que ela escreveu ao decidir.",
@@ -63,10 +56,6 @@ export const TOOLS_ESCALACAO = declararTools([
{
name: "crm_add_case_note",
category: "write",
description:
"Registra no caso o que aconteceu depois da abertura (kind='agent_noted', actor='agent') " +
"— o que o lead respondeu, o que já foi tentado, o que mudou. Só funciona em caso ABERTO. " +
"Serve para o próximo atendente não começar do zero.",
rotulo: "Registrar o que aconteceu num chamado",
explicacao:
"Deixa escrito no chamado o que mudou desde que ele foi aberto, para a pessoa que for atender não precisar começar do zero.",
@@ -77,10 +66,6 @@ export const TOOLS_ESCALACAO = declararTools([
{
name: "crm_close_human_case",
category: "write",
description:
"Encerra um caso aberto com o desfecho registrado: 'resolvido' (o bloqueio foi superado) " +
"ou 'sem_necessidade' (deixou de fazer sentido). Grava actor='agent' — NUNCA se passa por " +
"decisão humana. Irreversível pela própria capacidade.",
rotulo: "Encerrar um chamado com o desfecho",
explicacao:
"Fecha um chamado deixando registrado como ele terminou, para ele parar de ocupar a fila de quem atende. Não dá para reabrir por aqui.",
@@ -91,12 +76,6 @@ export const TOOLS_ESCALACAO = declararTools([
{
name: "crm_resume_ai_attendance",
category: "write",
description:
"Devolve o atendimento de uma conversa ao agente: solta o dono humano, limpa " +
"contacts.force_human e o silêncio do bot, e devolve o CONTEXTO do que a pessoa fez " +
"(decisões nos casos + notas internas). Grava esse contexto no checkpoint do lead, de " +
"onde o próximo turno o lê. Reativar automação para um cliente que pediu uma pessoa é " +
"decisão de peso — por isso é capacidade de risco crítico.",
rotulo: "Retomar o atendimento automático",
explicacao:
"Devolve a conversa para o atendimento automático depois que uma pessoa atendeu, levando junto o que ficou combinado com o cliente. Só uma pessoa pode acionar.",
+9 -19
View File
@@ -1,8 +1,15 @@
/**
* Capacidades de EVOLUCAO — o que a empresa ja sabe e o que o agente aprende.
*
* `description` fala com o modelo; `rotulo`/`explicacao`/`oQueToca` falam com o
* humano que configura o agente. Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
* ESTE ARQUIVO FALA COM O HUMANO que configura o agente — `rotulo`,
* `explicacao` e `oQueToca`. O texto que vai ao MODELO é a `description` do
* HANDLER (`lib/mcp/tools/<dominio>.ts`), e ela NÃO tem cópia aqui: até
* 2026-08-07 tinha, ninguém lia essa cópia, e 48 das 51 divergiam do que o
* modelo realmente recebia. O campo foi removido em vez de sincronizado —
* duplicata que ninguém lê não é documentação, é armadilha: um script de
* medição de vazamento chegou a montar o prompt com o texto errado, sob um
* comentário dizendo "a ferramenta como o modelo a vê".
* Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
*
* DECISAO DELIBERADA — a IA nao aprova a propria melhoria. As propostas do
* flywheel (`flywheel_distiller_proposals`) sao expostas para LEITURA. Nenhuma
@@ -17,10 +24,6 @@ export const TOOLS_EVOLUCAO = declararTools([
{
name: "crm_search_knowledge",
category: "read",
description:
"Busca semantica no acervo de conhecimento da organizacao (RAG). Devolve os trechos mais " +
"relevantes com a similaridade de cada um. Quando nada passa do limiar, devolve " +
"`melhor_similaridade` para distinguir 'nao ha nada sobre isso' de 'ha algo perto, mas fraco'.",
rotulo: "Consultar o que a empresa já sabe",
explicacao:
"Procura a resposta nos materiais que você cadastrou, para o assistente responder com a informação da sua empresa em vez de inventar.",
@@ -31,10 +34,6 @@ export const TOOLS_EVOLUCAO = declararTools([
{
name: "crm_list_knowledge_sources",
category: "read",
description:
"Lista os materiais que compoem o acervo de conhecimento da organizacao, com estado de " +
"indexacao e contagem de trechos. Util para saber se uma resposta faltou por ausencia de " +
"material ou por falha de indexacao.",
rotulo: "Ver os materiais cadastrados",
explicacao:
"Mostra quais materiais estão no acervo da empresa e se foram processados, para saber se faltou conteúdo ou se algo falhou.",
@@ -45,9 +44,6 @@ export const TOOLS_EVOLUCAO = declararTools([
{
name: "crm_list_improvement_proposals",
category: "read",
description:
"Lista as propostas de melhoria que o ciclo de aprendizado gerou, com tipo, alvo e a " +
"evidência que as motivou. LEITURA apenas: aprovar continua sendo decisão de uma pessoa.",
rotulo: "Ver sugestões de melhoria",
explicacao:
"Mostra as melhorias que o sistema sugeriu a partir dos atendimentos, com o motivo de cada uma. Aprovar continua sendo decisão sua.",
@@ -58,9 +54,6 @@ export const TOOLS_EVOLUCAO = declararTools([
{
name: "crm_get_org_memory",
category: "read",
description:
"Lê o que a empresa registrou sobre si mesma — políticas, combinados e aprendizados que " +
"valem para todo atendimento.",
rotulo: "Consultar as regras da empresa",
explicacao:
"Lê as políticas e combinados que valem para todo atendimento, para o assistente seguir a regra da casa em vez de inventar uma.",
@@ -71,9 +64,6 @@ export const TOOLS_EVOLUCAO = declararTools([
{
name: "crm_save_org_memory",
category: "write",
description:
"Registra um aprendizado que vale para toda a operação. Nasce com origem 'agent' para o " +
"humano distinguir o que a IA anotou do que ele mesmo escreveu.",
rotulo: "Anotar uma regra aprendida",
explicacao:
"Guarda um aprendizado que vale para todos os atendimentos, marcado como escrito pelo assistente para você distinguir do que anotou.",
+9 -8
View File
@@ -1,8 +1,15 @@
/**
* Capacidades de FUNIL — oportunidades de venda e as etapas por onde passam.
*
* `description` fala com o modelo; `rotulo`/`explicacao`/`oQueToca` falam com o
* humano que configura o agente. Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
* ESTE ARQUIVO FALA COM O HUMANO que configura o agente — `rotulo`,
* `explicacao` e `oQueToca`. O texto que vai ao MODELO é a `description` do
* HANDLER (`lib/mcp/tools/<dominio>.ts`), e ela NÃO tem cópia aqui: até
* 2026-08-07 tinha, ninguém lia essa cópia, e 48 das 51 divergiam do que o
* modelo realmente recebia. O campo foi removido em vez de sincronizado —
* duplicata que ninguém lê não é documentação, é armadilha: um script de
* medição de vazamento chegou a montar o prompt com o texto errado, sob um
* comentário dizendo "a ferramenta como o modelo a vê".
* Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
*/
import { declararTools } from "./tipos";
@@ -10,7 +17,6 @@ export const TOOLS_FUNIL = declararTools([
{
name: "crm_list_leads",
category: "read",
description: "Lista leads de um pipeline (com owner_user_name, stage, tags)",
rotulo: "Listar oportunidades do funil",
explicacao:
"Lista as oportunidades de venda de um funil, com a etapa em que cada uma está e quem é o responsável por ela.",
@@ -21,7 +27,6 @@ export const TOOLS_FUNIL = declararTools([
{
name: "crm_get_lead",
category: "read",
description: "Detalhe de lead (com owner_user_name, stage, tags)",
rotulo: "Ver uma oportunidade",
explicacao:
"Abre os detalhes de uma oportunidade de venda: etapa atual, responsável, marcadores e valor do negócio.",
@@ -32,7 +37,6 @@ export const TOOLS_FUNIL = declararTools([
{
name: "crm_list_pipelines",
category: "read",
description: "Lista pipelines da org",
rotulo: "Listar funis",
explicacao:
"Mostra os funis de venda existentes e suas etapas, para o agente saber onde pode colocar uma oportunidade.",
@@ -43,7 +47,6 @@ export const TOOLS_FUNIL = declararTools([
{
name: "crm_create_lead",
category: "write",
description: "Cria um lead",
rotulo: "Criar oportunidade no funil",
explicacao:
"Registra uma nova oportunidade de venda no funil, para que o interesse demonstrado pelo cliente não se perca.",
@@ -54,7 +57,6 @@ export const TOOLS_FUNIL = declararTools([
{
name: "crm_update_lead",
category: "write",
description: "Atualiza campos de um lead",
rotulo: "Atualizar uma oportunidade",
explicacao:
"Altera dados de uma oportunidade de venda: valor do negócio, responsável e informações colhidas na conversa.",
@@ -65,7 +67,6 @@ export const TOOLS_FUNIL = declararTools([
{
name: "crm_move_lead_stage",
category: "write",
description: "Move lead para outro stage",
rotulo: "Mover oportunidade de etapa",
explicacao:
"Move a oportunidade para outra etapa do funil, registrando o avanço da negociação ou a perda do negócio.",
+9 -6
View File
@@ -2,8 +2,15 @@
* Capacidades de GOVERNANCA — fila, direcionamento, marcadores e a passagem
* do atendimento automatico para uma pessoa.
*
* `description` fala com o modelo; `rotulo`/`explicacao`/`oQueToca` falam com o
* humano que configura o agente. Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
* ESTE ARQUIVO FALA COM O HUMANO que configura o agente — `rotulo`,
* `explicacao` e `oQueToca`. O texto que vai ao MODELO é a `description` do
* HANDLER (`lib/mcp/tools/<dominio>.ts`), e ela NÃO tem cópia aqui: até
* 2026-08-07 tinha, ninguém lia essa cópia, e 48 das 51 divergiam do que o
* modelo realmente recebia. O campo foi removido em vez de sincronizado —
* duplicata que ninguém lê não é documentação, é armadilha: um script de
* medição de vazamento chegou a montar o prompt com o texto errado, sob um
* comentário dizendo "a ferramenta como o modelo a vê".
* Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
*/
import { declararTools } from "./tipos";
@@ -11,7 +18,6 @@ export const TOOLS_GOVERNANCA = declararTools([
{
name: "crm_get_queue_status",
category: "read",
description: "Snapshot da fila de atendimento da org",
rotulo: "Ver a fila de atendimento",
explicacao:
"Mostra quantas pessoas estão esperando atendimento agora e há quanto tempo, para priorizar quem espera mais.",
@@ -22,7 +28,6 @@ export const TOOLS_GOVERNANCA = declararTools([
{
name: "crm_assign_conversation",
category: "write",
description: "Atribui/transfere/libera uma conversa",
rotulo: "Direcionar conversa para alguém",
explicacao:
"Passa a conversa para um atendente, transfere para outra pessoa ou devolve o cliente para a fila de espera.",
@@ -33,7 +38,6 @@ export const TOOLS_GOVERNANCA = declararTools([
{
name: "crm_manage_tags",
category: "write",
description: "Adiciona/remove tags em conversation/contact/lead",
rotulo: "Aplicar marcadores",
explicacao:
"Adiciona ou remove marcadores numa conversa, cliente ou oportunidade, para organizar e filtrar a operação depois.",
@@ -44,7 +48,6 @@ export const TOOLS_GOVERNANCA = declararTools([
{
name: "crm_request_human_handoff",
category: "handoff",
description: "Solicita handoff para atendente humano",
rotulo: "Chamar um atendente humano",
explicacao:
"Interrompe o atendimento automático e chama uma pessoa, entregando um resumo do que já aconteceu na conversa.",
+9 -17
View File
@@ -3,8 +3,15 @@
* etapas do funil, marcadores, respostas prontas, entradas automáticas de
* contatos, regras automáticas e time.
*
* `description` fala com o modelo; `rotulo`/`explicacao`/`oQueToca` falam com o
* humano que configura o agente. Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
* ESTE ARQUIVO FALA COM O HUMANO que configura o agente — `rotulo`,
* `explicacao` e `oQueToca`. O texto que vai ao MODELO é a `description` do
* HANDLER (`lib/mcp/tools/<dominio>.ts`), e ela NÃO tem cópia aqui: até
* 2026-08-07 tinha, ninguém lia essa cópia, e 48 das 51 divergiam do que o
* modelo realmente recebia. O campo foi removido em vez de sincronizado —
* duplicata que ninguém lê não é documentação, é armadilha: um script de
* medição de vazamento chegou a montar o prompt com o texto errado, sob um
* comentário dizendo "a ferramenta como o modelo a vê".
* Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
*
* ⚠️ SOBRE O RISCO `critico` DESTE ARQUIVO. O gate mecânico
* (`tests/unit/catalogo-tools-leigo-friendly.test.ts`) só sabe conferir que uma
@@ -51,7 +58,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_list_stages",
category: "read",
description: "Lista as etapas ativas de um pipeline, na ordem do quadro",
rotulo: "Ver as etapas de um funil",
explicacao:
"Mostra as colunas de um funil na ordem em que aparecem no quadro, para o agente saber onde pode colocar cada negócio.",
@@ -62,7 +68,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_create_stage",
category: "write",
description: "Cria uma etapa no fim do pipeline",
rotulo: "Criar etapa no funil",
explicacao:
"Acrescenta uma coluna nova no fim do funil, quando o jeito de trabalhar da empresa tem um passo que ainda não está no quadro.",
@@ -74,7 +79,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_update_stage",
category: "write",
description: "Renomeia, reordena ou muda o papel de desfecho de uma etapa",
rotulo: "Renomear ou reordenar uma etapa",
explicacao:
"Troca o nome de uma coluna do funil, muda o lugar dela na ordem ou define em qual delas o negócio é dado como fechado ou perdido.",
@@ -86,7 +90,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_archive_stage",
category: "write",
description: "Arquiva uma etapa e move os leads dela para outra",
rotulo: "Arquivar uma etapa do funil",
explicacao:
"Tira uma coluna do quadro e leva os negócios que estavam nela para outra coluna que você escolher. O histórico continua guardado.",
@@ -100,7 +103,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_list_tags",
category: "read",
description: "Lista as tags em uso na org, com contagem e origem",
rotulo: "Ver os marcadores em uso",
explicacao:
"Mostra quais marcadores a empresa já usa e em quantas conversas, para o agente reaproveitar em vez de inventar outro parecido.",
@@ -113,7 +115,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_list_message_templates",
category: "read",
description: "Lista os message_templates da org",
rotulo: "Ver as respostas prontas",
explicacao:
"Lista os textos que a empresa já escreveu para responder as situações de sempre, com o atalho de cada um.",
@@ -124,7 +125,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_render_message_template",
category: "read",
description: "Preenche um template com dados do contato/lead e devolve o texto",
rotulo: "Preencher uma resposta pronta",
explicacao:
"Pega uma resposta pronta e troca as lacunas pelos dados do cliente, avisando se sobrou alguma sem preencher. Não envia nada.",
@@ -137,7 +137,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_list_webhook_sources",
category: "read",
description: "Lista as webhook_sources da org com destino e último recebimento",
rotulo: "Ver as entradas automáticas de contatos",
explicacao:
"Mostra por onde chegam sozinhos os contatos vindos do site ou de outro sistema, se cada uma está ligada e quando recebeu pela última vez.",
@@ -148,7 +147,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_list_webhook_source_events",
category: "read",
description: "Últimos recebimentos de uma webhook_source (só as chaves do corpo)",
rotulo: "Ver o que chegou por uma entrada",
explicacao:
"Mostra os últimos contatos que entraram por uma origem e quais informações vieram, para descobrir por que algo não chegou como devia.",
@@ -159,7 +157,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_create_webhook_source",
category: "write",
description: "Cria uma webhook_source e devolve o path_token da URL",
rotulo: "Criar uma entrada automática de contatos",
explicacao:
"Abre um endereço novo para o site da empresa mandar contatos direto para um funil. A partir daí ele passa a receber gente de fora sozinho.",
@@ -171,7 +168,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_set_webhook_source_active",
category: "write",
description: "Liga/desliga uma webhook_source (is_active)",
rotulo: "Ligar ou desligar uma entrada de contatos",
explicacao:
"Faz uma origem voltar a receber contatos, ou parar. Desligada, o formulário do seu site continua no ar e ninguém do outro lado é avisado.",
@@ -185,7 +181,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_list_automation_rules",
category: "read",
description: "Lista as automation_rules com gatilho e tipos de ação",
rotulo: "Ver as regras automáticas",
explicacao:
"Mostra o que a empresa deixou configurado para acontecer sozinho, o que dispara cada regra e se ela está ligada.",
@@ -196,7 +191,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_list_automation_runs",
category: "read",
description: "Histórico de automation_rule_runs, com detalhe só das falhas",
rotulo: "Ver o que as regras dispararam",
explicacao:
"Mostra o que rodou sozinho nos últimos tempos e o que deu errado, para descobrir o que parou de funcionar sem ninguém perceber.",
@@ -207,7 +201,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_set_automation_rule_active",
category: "write",
description: "Liga/desliga uma automation_rule (is_active)",
rotulo: "Ligar ou desligar uma regra automática",
explicacao:
"Faz uma regra passar a rodar sozinha, sempre que o gatilho dela acontecer, ou parar de rodar. Ligada, ela pode falar com clientes de verdade.",
@@ -221,7 +214,6 @@ export const TOOLS_OPERACAO = declararTools([
{
name: "crm_list_team_members",
category: "read",
description: "Lista user_organizations ativos com papel (sem PII)",
rotulo: "Ver quem trabalha na empresa",
explicacao:
"Lista as pessoas do time e o que cada uma pode fazer aqui dentro, para o agente saber para quem passar um atendimento.",
+9 -29
View File
@@ -11,8 +11,15 @@
* cancelar, listar retorno) e registrar o desfecho (encerrar a demanda). Sem a
* segunda, o anti-morte fica pela metade e o radar enche de negócio que já acabou.
*
* `description` fala com o modelo; `rotulo`/`explicacao`/`oQueToca` falam com o
* humano que configura o agente. Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
* ESTE ARQUIVO FALA COM O HUMANO que configura o agente — `rotulo`,
* `explicacao` e `oQueToca`. O texto que vai ao MODELO é a `description` do
* HANDLER (`lib/mcp/tools/<dominio>.ts`), e ela NÃO tem cópia aqui: até
* 2026-08-07 tinha, ninguém lia essa cópia, e 48 das 51 divergiam do que o
* modelo realmente recebia. O campo foi removido em vez de sincronizado —
* duplicata que ninguém lê não é documentação, é armadilha: um script de
* medição de vazamento chegou a montar o prompt com o texto errado, sob um
* comentário dizendo "a ferramenta como o modelo a vê".
* Ver `docs/handoffs/BRIEFING-ia-360.md` §4.
*
* ⚠️ "FOLLOW-UP" NÃO APARECE NO TEXTO DO HUMANO. Para o dono da clínica isso se
* chama RETORNO ou acompanhamento — o gate mecânico
@@ -25,11 +32,6 @@ export const TOOLS_RETENCAO = declararTools([
{
name: "crm_schedule_followup",
category: "write",
description:
"Agenda um retorno futuro ao cliente (cron_job one-shot kind='at', job_kind='followup_turn'). " +
"Informe lead_id OU contact_id; promised_at é ISO 8601 ABSOLUTO e no futuro, dentro da janela " +
"aceitável da organização. Recusa se já houver retorno vivo para o contato (1 por contato — " +
"anti-empilhamento). Emite atividade na timeline do negócio.",
rotulo: "Agendar um retorno para o cliente",
explicacao:
"Marca um horário para o agente voltar a falar com o cliente, para que a conversa não morra sem resposta.",
@@ -44,10 +46,6 @@ export const TOOLS_RETENCAO = declararTools([
{
name: "crm_cancel_followup",
category: "write",
description:
"Cancela um retorno agendado que ainda não disparou (marca cancelled_at + cancel_reason). " +
"Use quando o cliente já respondeu ou o motivo do retorno deixou de existir. " +
"Recusa se o retorno já disparou ou já foi cancelado. Emite atividade na timeline.",
rotulo: "Cancelar um retorno agendado",
explicacao:
"Desmarca um retorno que ainda não aconteceu, para o agente não insistir com quem já respondeu.",
@@ -58,10 +56,6 @@ export const TOOLS_RETENCAO = declararTools([
{
name: "crm_list_followups",
category: "read",
description:
"Lista os retornos de um cliente (informe lead_id OU contact_id), do mais próximo para o mais " +
"antigo, com a situação de cada um: agendado, disparado ou cancelado (com o motivo). " +
"É por aqui que o agente descobre que um humano desmarcou um retorno.",
rotulo: "Ver os retornos de um cliente",
explicacao:
"Mostra os retornos combinados com o cliente: o que está marcado, o que já aconteceu e o que foi desmarcado.",
@@ -72,12 +66,6 @@ export const TOOLS_RETENCAO = declararTools([
{
name: "crm_list_at_risk_leads",
category: "read",
description:
"Radar de risco: oportunidades ABERTAS que passaram da janela de esfriamento do próprio " +
"estágio, classificadas em critico / em_risco / em_voo (em_voo = há retorno agendado). " +
"Ordenado por urgência. Aceita limit e min_hours. Traz também `sem_proximo_passo`: " +
"demandas abertas sem nada marcado para acontecer, resolvíveis com crm_schedule_followup " +
"(por contact_id) ou crm_close_demand.",
rotulo: "Ver quem esfriou e quem ficou sem próximo passo",
explicacao:
"Lista as oportunidades abertas que passaram do prazo sem movimento, das mais críticas para as menos " +
@@ -89,10 +77,6 @@ export const TOOLS_RETENCAO = declararTools([
{
name: "crm_close_demand",
category: "write",
description:
"Encerra a demanda movendo a oportunidade para o estágio terminal do funil: outcome='won' ou " +
"outcome='lost' (neste, reason é obrigatório). O status e a data de fechamento são do trigger. " +
"Idempotente. Emite atividade na timeline.",
rotulo: "Encerrar o negócio como ganho ou perdido",
explicacao:
"Fecha a oportunidade dizendo se ela foi ganha ou perdida e por quê, para o que já acabou parar de ser cobrado.",
@@ -106,10 +90,6 @@ export const TOOLS_RETENCAO = declararTools([
{
name: "crm_propose_reactivation",
category: "write",
description:
"Cria uma proposta de reativação (crm_lead_reactivations, status='pending') para uma " +
"oportunidade que esfriou. A proposta VENCE sozinha na mesma janela do esfriamento e o envio " +
"só acontece se um humano aprovar. Recusa se já houver proposta viva ou se não houver contato.",
rotulo: "Sugerir retomar contato com quem sumiu",
explicacao:
"Cria uma sugestão de retomar o contato com um cliente que esfriou, para uma pessoa aprovar antes de qualquer envio.",
-2
View File
@@ -13,8 +13,6 @@ export interface McpToolCatalogEntry {
name: string;
/** Dirige requiresScope/requiresRole. Nao e rotulo de tela. */
category: McpToolCategory;
/** Texto tecnico entregue ao MODELO. */
description: string;
// ---- camada de apresentacao: o que o HUMANO le ----
/** Verbo no infinitivo, pt-BR. Ex: "Mover oportunidade de etapa". */
+1 -1
View File
@@ -14,7 +14,7 @@
import { chromium } from "@playwright/test";
import { readFileSync } from "node:fs";
const BASE = "http://127.0.0.1:3100";
const BASE = process.env.E2E_PORT ? `http://127.0.0.1:${process.env.E2E_PORT}` : "http://127.0.0.1:3100";
const AGENTE = "36ed74d7-3505-46ab-bc2b-9656626e118c"; // kind=mcp_agent — o rag_bot cai no editor legado, sem ToolPicker
const c = JSON.parse(readFileSync("/Users/rafaelmelgaco/DeskcommCRM/.e2e-creds.json", "utf8"));
+23
View File
@@ -100,4 +100,27 @@ describe("catálogo real ↔ handlers reais", () => {
expect(s.pacotes.length, `${s.id} sem pacote`).toBeGreaterThan(0);
}
});
it("toda capacidade servida tem descrição — e ela vem do HANDLER", () => {
// Esta guarda faltava, e a falta ficou visível em 2026-08-07: o catálogo
// tinha uma `description` própria que NENHUM consumidor lia (a ponte do
// turno monta `def.description`, do handler) e que divergia da real em 48
// das 51 capacidades. Ela foi removida; o campo do catálogo não existe mais.
//
// Sem este caso, esvaziar a descrição de um handler passaria calado: a tela
// mostraria a capacidade sem explicação técnica e o modelo receberia uma
// ferramenta sem contrato — dois silêncios de uma vez.
const servidas = juntarCatalogoComHandlers(allTools, TOOL_CATALOG);
const porNome = new Map(allTools.map((t) => [t.name, t.description]));
expect(servidas.length, "nada servido — guarda de vacuidade").toBeGreaterThan(0);
for (const s of servidas) {
expect(s.description.trim().length, `${s.id} servido sem descrição`).toBeGreaterThan(0);
// A IDENTIDADE é o que importa: não basta ter texto, tem de ser o MESMO
// que chega ao modelo. Se um dia reaparecer uma cópia no catálogo e a
// junção passar a preferi-la, este caso reprova.
expect(s.description, `${s.id}: a tela mostra texto diferente do que vai ao modelo`).toBe(
porNome.get(s.id),
);
}
});
});