Blog

Testes de regressão para ferramentas do agente: contrato antes do prompt

O agente parou de consultar o rastreamento de pedido e ninguém mexeu no prompt. O que mudou foi a API interna: o campo "status" virou "orderStatus", a resposta de erro deixou de ter "message" e o parâmetro de data passou a exigir fuso. Nada disso quebrou um teste, porque a suíte do time testa a API e testa o prompt, e o que quebrou fica exatamente entre os dois. Ferramenta de agente não é só uma função: é um contrato com três faces que envelhecem em ritmos diferentes, e a face que ninguém versiona é justamente a que o modelo lê. Este artigo trata teste de regressão de ferramenta como engenharia de contrato: quais três camadas precisam de teste separado, como um snapshot do esquema pega a mudança que o compilador não pega, por que testar seleção de ferramenta com o modelo real custa caro e como reduzir esse custo sem perder o sinal, e qual portão colocar no pipeline para que a mudança de contrato pare antes de chegar em produção.

2026-08-15 / IA Aplicada / 15 min

01

A ferramenta tem três contratos, e o time testa só um

Quando um desenvolvedor escreve uma ferramenta para o agente, ele enxerga uma função: recebe argumentos, chama um serviço, devolve dados. O teste que ele escreve reflete essa visão e cobre bem o caminho da execução. Só que o modelo não interage com a função, ele interage com a descrição dela. Entre o prompt e o serviço existem três contratos independentes, e cada um pode regredir sozinho sem derrubar os outros dois.

O primeiro é o contrato de descoberta: nome da ferramenta, descrição, nomes e descrições dos parâmetros, enumerações, obrigatoriedade. É o texto que o modelo lê para decidir se aquela ferramenta serve para a pergunta. O segundo é o contrato de invocação: o esquema que valida os argumentos que o modelo produziu, incluindo tipos, formatos e valores aceitos. O terceiro é o contrato de retorno: a forma dos dados que voltam para dentro da janela de contexto e viram base da resposta ao cliente. Renomear um campo do retorno não quebra o esquema de entrada, não quebra a chamada HTTP e não gera exceção. Só faz o agente responder que não encontrou a informação.

CamadaQuem consomeComo regride na práticaTeste que pega
DescobertaO modelo, ao escolher a ferramentaDescrição reescrita para ficar "mais clara" e a taxa de seleção caiSuíte de seleção com casos rotulados
InvocaçãoO validador de argumentosParâmetro vira obrigatório ou muda de formato de dataSnapshot do esquema mais teste de compatibilidade
RetornoO modelo, ao redigir a respostaCampo renomeado ou aninhado em um nível novoContrato de resposta com dados gravados
Efeito colateralO sistema de destinoAção passa a exigir confirmação ou vira assíncronaTeste de integração com serviço real ou dublê fiel
ErroO modelo, ao decidir se tenta de novoFormato de erro muda e o agente entra em laço de repetiçãoCasos de falha explícitos na suíte

A última linha merece atenção porque produz o incidente mais caro da lista. Quando o formato de erro muda, o modelo perde a única pista que tinha sobre o que fazer a seguir, e o comportamento padrão de quase todo agente nessa situação é tentar de novo com os mesmos argumentos. O resultado é uma conversa que consome dez chamadas de ferramenta e três vezes o orçamento de tokens para terminar em uma resposta genérica de indisponibilidade. Nenhum teste de API pega isso, porque do ponto de vista da API o erro foi devolvido corretamente.

02

Congelar o esquema: o teste mais barato que existe

Antes de qualquer coisa que envolva o modelo, existe um teste que custa milissegundos e pega a maior parte das regressões de contrato: gravar o esquema exposto ao modelo em um arquivo versionado e falhar quando ele mudar sem intenção. É o mesmo princípio de um teste de snapshot de interface, aplicado ao artefato que o modelo enxerga. A diferença em relação a um snapshot comum é que aqui a comparação não pode ser textual bruta, senão qualquer reordenação de chaves gera falso positivo e o time aprende a atualizar o arquivo sem ler.

O que funciona é normalizar o esquema, gerar um hash por ferramenta e classificar a diferença. Adicionar um parâmetro opcional é compatível. Adicionar um obrigatório, remover um valor de enumeração, apertar um formato ou renomear um campo é quebra. Mudar só a descrição não quebra a invocação, mas muda a descoberta, então precisa de um aviso diferente: não bloqueia o merge, mas exige que a suíte de seleção rode.

// tools/schema-contract.js
// Congela o contrato exposto ao modelo e classifica a diferenca entre a
// versao gravada e a atual. Roda em milissegundos, sem chamar o provedor.

import { createHash } from 'node:crypto';

// Normaliza para que reordenacao de chaves nao gere falso positivo.
// A descricao entra num hash separado: ela nao quebra a invocacao,
// mas muda a descoberta e precisa disparar a suite de selecao.
function normalizeParameters(schema) {
  if (!schema || typeof schema !== 'object') return schema;
  if (Array.isArray(schema)) return schema.map(normalizeParameters);

  return Object.keys(schema)
    .filter((key) => key !== 'description')
    .sort()
    .reduce((acc, key) => {
      acc[key] = normalizeParameters(schema[key]);
      return acc;
    }, {});
}

function hashOf(value) {
  return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
}

export function fingerprintTool(tool) {
  return {
    name: tool.name,
    invocationHash: hashOf(normalizeParameters(tool.parameters)),
    discoveryHash: hashOf({
      description: tool.description,
      parameterDescriptions: collectDescriptions(tool.parameters),
    }),
    required: [...(tool.parameters?.required ?? [])].sort(),
    enums: collectEnums(tool.parameters),
  };
}

function collectDescriptions(schema, path = '', out = {}) {
  if (!schema || typeof schema !== 'object') return out;
  if (typeof schema.description === 'string') out[path || '.'] = schema.description;
  for (const [key, value] of Object.entries(schema.properties ?? {})) {
    collectDescriptions(value, `${path}.${key}`, out);
  }
  return out;
}

function collectEnums(schema, path = '', out = {}) {
  if (!schema || typeof schema !== 'object') return out;
  if (Array.isArray(schema.enum)) out[path || '.'] = [...schema.enum].sort();
  for (const [key, value] of Object.entries(schema.properties ?? {})) {
    collectEnums(value, `${path}.${key}`, out);
  }
  return out;
}

// Classifica a mudanca em vez de apenas apontar diferenca. O time precisa
// saber se aquilo bloqueia o merge ou apenas exige rodar a suite de selecao.
export function diffContracts(baseline, current) {
  const findings = [];
  const byName = new Map(current.map((tool) => [tool.name, tool]));

  for (const before of baseline) {
    const after = byName.get(before.name);

    if (!after) {
      findings.push({ tool: before.name, level: 'breaking', reason: 'ferramenta removida' });
      continue;
    }

    if (before.invocationHash !== after.invocationHash) {
      const newRequired = after.required.filter((key) => !before.required.includes(key));
      const lostEnum = Object.entries(before.enums).flatMap(([path, values]) =>
        values.filter((value) => !(after.enums[path] ?? []).includes(value)),
      );

      findings.push({
        tool: before.name,
        level: newRequired.length || lostEnum.length ? 'breaking' : 'compatible',
        reason: newRequired.length
          ? `novos parametros obrigatorios: ${newRequired.join(', ')}`
          : lostEnum.length
            ? `valores de enum removidos: ${lostEnum.join(', ')}`
            : 'esquema alterado de forma compativel',
      });
    }

    if (before.discoveryHash !== after.discoveryHash) {
      findings.push({
        tool: before.name,
        level: 'behavioral',
        reason: 'descricao mudou, rode a suite de selecao antes do merge',
      });
    }
  }

  for (const after of current) {
    if (!baseline.some((before) => before.name === after.name)) {
      findings.push({ tool: after.name, level: 'behavioral', reason: 'ferramenta nova' });
    }
  }

  return findings;
}

A escolha de separar o hash de invocação do hash de descoberta é o detalhe que faz esse teste sobreviver ao contato com o time. Se qualquer ajuste de redação bloquear o merge, alguém vai passar a regenerar o arquivo de referência por reflexo em duas semanas, e o teste vira decoração. Separando as duas faces, ajuste de texto vira aviso que dispara a suíte cara, e mudança estrutural vira bloqueio, que é o comportamento proporcional em cada caso.

03

Testar seleção de ferramenta sem quebrar o orçamento

O esquema congelado não responde a pergunta que mais importa: dada a pergunta do cliente, o agente ainda escolhe a ferramenta certa com os argumentos certos? Essa camada exige o modelo no laço, porque é comportamento e não estrutura. E é aqui que quase todo time desiste, porque rodar duzentos casos com o modelo grande a cada pull request tem custo e latência que ninguém aceita no caminho do merge.

A saída não é reduzir a cobertura, é separar o que roda sempre do que roda por evento. Um conjunto pequeno de casos críticos, algo entre vinte e quarenta, roda em todo pull request que toca ferramenta ou prompt. O conjunto completo roda quando o diff de contrato aponta mudança comportamental, quando o modelo muda de versão e uma vez por dia no agendado. O barateamento vem de um detalhe técnico simples e muito eficaz: nesse teste não é preciso executar a ferramenta nem gerar a resposta final. Basta pedir a primeira decisão do modelo e parar ali, o que corta a maior parte dos tokens de saída e todo o custo do serviço de destino.

// tools/selection-suite.js
// Suite de regressao de selecao de ferramenta. Nao executa a ferramenta nem
// gera a resposta final: pede so a primeira decisao e para ali.

const DEFAULT_THRESHOLDS = {
  minToolAccuracy: 0.95,   // escolheu a ferramenta certa
  minArgAccuracy: 0.9,     // preencheu os argumentos criticos certos
  maxSpuriousCalls: 0.02,  // chamou ferramenta em caso que nao precisava
};

export function createSelectionSuite({ callModel, tools, cases, thresholds = {} }) {
  const limits = { ...DEFAULT_THRESHOLDS, ...thresholds };

  async function runCase(testCase) {
    const decision = await callModel({
      messages: [{ role: 'user', content: testCase.userMessage }],
      tools,
      toolChoice: 'auto',
      maxTokens: 512,
    });

    const call = decision.toolCalls?.[0] ?? null;

    // Caso negativo: a pergunta deve ser respondida sem ferramenta.
    // Testar isso importa tanto quanto o positivo, porque descricao
    // ampla demais faz o agente chamar ferramenta para "bom dia".
    if (testCase.expectedTool === null) {
      return { id: testCase.id, ok: call === null, kind: 'negative', got: call?.name ?? null };
    }

    if (!call) {
      return { id: testCase.id, ok: false, kind: 'missing', got: null };
    }

    if (call.name !== testCase.expectedTool) {
      return { id: testCase.id, ok: false, kind: 'wrong-tool', got: call.name };
    }

    // Compara apenas os argumentos criticos. Exigir igualdade exata do objeto
    // inteiro transforma a suite numa fabrica de falso positivo, porque o
    // modelo preenche campos opcionais de forma legitimamente variavel.
    const wrongArgs = Object.entries(testCase.expectedArguments ?? {}).filter(
      ([key, expected]) => !argumentMatches(call.arguments?.[key], expected),
    );

    return {
      id: testCase.id,
      ok: wrongArgs.length === 0,
      kind: wrongArgs.length ? 'wrong-args' : 'pass',
      got: Object.fromEntries(wrongArgs.map(([key]) => [key, call.arguments?.[key]])),
    };
  }

  async function run() {
    const results = [];
    for (const testCase of cases) {
      results.push(await runCase(testCase));
    }

    const positives = results.filter((r) => r.kind !== 'negative');
    const negatives = results.filter((r) => r.kind === 'negative');

    const toolAccuracy =
      positives.filter((r) => r.kind !== 'wrong-tool' && r.kind !== 'missing').length /
      Math.max(positives.length, 1);
    const argAccuracy = positives.filter((r) => r.ok).length / Math.max(positives.length, 1);
    const spuriousRate =
      negatives.filter((r) => !r.ok).length / Math.max(negatives.length, 1);

    const failures = [];
    if (toolAccuracy < limits.minToolAccuracy) failures.push(`selecao ${toolAccuracy.toFixed(3)}`);
    if (argAccuracy < limits.minArgAccuracy) failures.push(`argumentos ${argAccuracy.toFixed(3)}`);
    if (spuriousRate > limits.maxSpuriousCalls) failures.push(`espurias ${spuriousRate.toFixed(3)}`);

    return {
      approved: failures.length === 0,
      failures,
      metrics: { toolAccuracy, argAccuracy, spuriousRate },
      results,
    };
  }

  return { run, runCase };
}

function argumentMatches(actual, expected) {
  if (expected instanceof RegExp) return typeof actual === 'string' && expected.test(actual);
  if (typeof expected === 'function') return Boolean(expected(actual));
  return JSON.stringify(actual) === JSON.stringify(expected);
}

Dois pontos desse código valem mais do que o resto. O primeiro é o caso negativo, que quase nunca aparece nas suítes que vejo: um conjunto de perguntas que o agente deve responder sem chamar nada. Descrição larga demais produz um agente que consulta o estoque para responder "bom dia", e esse desperdício não aparece em nenhuma métrica de acerto se você só testa casos positivos. O segundo é comparar apenas os argumentos críticos, com expressão regular ou predicado em vez de igualdade exata: o modelo preenche campos opcionais de forma legitimamente variável, e exigir o objeto inteiro idêntico gera falha em toda execução até o time silenciar a suíte.

04

O retorno da ferramenta também é contrato

A regressão mais silenciosa acontece depois da chamada dar certo. A ferramenta responde com status duzentos, o esquema de entrada validou, nenhum log registra nada estranho, e mesmo assim o agente diz ao cliente que não conseguiu localizar o pedido. Isso acontece porque o modelo lê o corpo do retorno como texto, e quando "status" vira "orderStatus" aninhado dentro de "fulfillment", a informação continua lá mas deixou de estar onde a instrução mandava procurar.

A defesa é tratar o retorno com o mesmo rigor da entrada: definir um esquema de saída explícito, mapear a resposta do serviço para esse esquema em uma camada de adaptação e testar esse mapeamento com respostas reais gravadas. Gravar respostas reais é o passo que a maioria pula, e é o que dá valor ao teste, porque a mudança que quebra nunca é a que você imaginou ao escrever o dublê à mão.

  1. Defina um esquema de saída para cada ferramenta, com os campos que a instrução do agente realmente cita, e trate o resto como opcional.
  2. Coloque uma camada de adaptação entre o serviço e o agente, para que mudança de campo do fornecedor pare ali em vez de vazar para o contexto.
  3. Grave respostas reais do serviço em ambiente de homologação, incluindo pelo menos um sucesso, um vazio, um erro de negócio e um erro de infraestrutura.
  4. Rode o mapeamento contra essas respostas gravadas em todo pull request, verificando que os campos citados na instrução continuam presentes e no mesmo lugar.
  5. Renove as respostas gravadas em cadência fixa, porque dublê que envelhece esconde exatamente a mudança que você quer detectar.
  6. Falhe alto quando um campo obrigatório do esquema de saída sumir, em vez de repassar um objeto incompleto para dentro da janela de contexto.

O quinto item é o que decide se toda essa estrutura vale alguma coisa em seis meses. Uma suíte com respostas gravadas há um ano prova que o seu código continua compatível com uma API que não existe mais nesse formato, e passa uma sensação de segurança pior do que não ter teste nenhum. Uma tarefa agendada que regrava os dublês contra homologação semanalmente e abre um pull request quando o formato muda resolve isso, e transforma a mudança do fornecedor em uma revisão de dez minutos em vez de um incidente.

Onde cada teste entra no caminho da ferramenta

  pergunta do cliente
        |
        v
  [ modelo escolhe ]  <-- suite de selecao (modelo real, casos rotulados)
        |                  mede: ferramenta certa, argumentos, chamada espuria
        v
  [ valida esquema ]  <-- snapshot de contrato (milissegundos, sem provedor)
        |                  mede: obrigatorio novo, enum removido, campo renomeado
        v
  [ chama servico  ]  <-- integracao com dublê gravado de resposta real
        |                  mede: sucesso, vazio, erro de negocio, erro de infra
        v
  [ adapta retorno ]  <-- contrato de saida (campos citados na instrucao)
        |                  mede: campo sumiu, aninhou, mudou de tipo
        v
  [ modelo redige  ]  <-- eval de resposta (caro, roda por evento)

  regra de custo: quanto mais alto no caminho, mais barato o teste
  e mais cedo ele deve falhar

05

O portão no pipeline: o que bloqueia e o que apenas avisa

Ter os testes não adianta se o pipeline os trata todos igual. Um portão que bloqueia por qualquer diferença faz o time criar o hábito de forçar o merge, e um portão que só avisa não impede nada. A configuração que funciona liga o nível da falha ao tipo de mudança detectada, e usa o resultado do diff de contrato para decidir quais suítes caras precisam rodar naquele pull request específico.

Sinal detectadoAção no pipelineSuíte cara roda?Justificativa
Parâmetro opcional adicionadoPassa, atualiza o arquivo de referênciaNãoCompatível com chamadas existentes
Parâmetro obrigatório novoBloqueia até aprovação explícitaSimToda chamada anterior do modelo passa a ser inválida
Descrição reescritaPassa com avisoSimNão quebra a invocação, mas muda a escolha do modelo
Valor de enumeração removidoBloqueiaSimO modelo aprendeu a produzir um valor que agora é recusado
Campo do retorno renomeadoBloqueiaNãoO contrato de saída já prova a quebra sem custo de modelo
Versão do modelo alteradaBloqueia até a suíte completa passarSim, completaSeleção de ferramenta muda entre versões sem aviso

A última linha é a que mais gente esquece de configurar e a que mais dói. Trocar a versão do modelo não altera uma linha do seu código, então nenhum diff dispara, nenhum teste de esquema falha e o pull request nem existe, porque a troca costuma ser uma variável de ambiente. O comportamento de seleção de ferramenta, porém, muda entre versões: uma descrição que era suficientemente clara para a versão anterior pode passar a competir com outra ferramenta na nova. Amarrar a suíte completa à mudança do identificador do modelo, e não apenas ao diff de código, é o que fecha essa porta.

Sobre o volume, vale ser concreto para o portão não virar uma discussão eterna. Um conjunto crítico de trinta casos, com a chamada parando na primeira decisão e sem executar ferramenta, custa alguns centavos por execução e termina em menos de um minuto quando os casos rodam em paralelo. Isso cabe em qualquer pull request. O conjunto completo, com duzentos ou trezentos casos incluindo os negativos, cabe bem em uma execução diária e nas trocas de versão. Quando alguém argumentar que a suíte é cara, o número a comparar não é o custo dela e sim o custo de uma semana com quinze por cento das conversas escolhendo a ferramenta errada.

06

Onde os casos de teste realmente vêm

A suíte só tem valor se os casos representam o que os clientes perguntam, e casos escritos em uma reunião de planejamento representam o que o time imagina que eles perguntam. A diferença entre as duas coisas costuma ser grande, e aparece como uma suíte de noventa e oito por cento de acerto convivendo com um agente que erra ferramenta o tempo todo em produção.

A fonte certa é o tráfego real, e existem quatro veios que rendem mais do que qualquer sessão de brainstorm. O primeiro são as conversas que terminaram em transferência para humano logo depois de uma chamada de ferramenta, porque quase sempre a ferramenta escolhida foi a errada. O segundo são as conversas com três ou mais chamadas seguidas da mesma ferramenta, que indicam laço de repetição por erro mal formatado. O terceiro são as chamadas que falharam na validação de argumentos, que já vêm com o rótulo pronto. O quarto, e o mais valioso, são as perguntas que geraram chamada de ferramenta e deveriam ter sido respondidas direto, que viram os casos negativos que quase ninguém tem.

// tools/mine-cases.js
// Extrai candidatos a caso de teste do trafego real. Nao rotula sozinho:
// entrega uma fila priorizada para revisao humana, com o motivo do palpite.

export async function mineToolCases({ db, since, limit = 200 }) {
  const rows = await db.query(
    `SELECT c.conversation_id,
            c.first_user_message,
            c.tool_calls,
            c.handoff_at,
            c.validation_errors
       FROM agent_conversations c
      WHERE c.started_at >= $1
        AND jsonb_array_length(c.tool_calls) > 0
      ORDER BY c.started_at DESC
      LIMIT $2`,
    [since, limit],
  );

  const candidates = [];

  for (const row of rows) {
    const calls = row.tool_calls ?? [];
    const firstCall = calls[0];

    // Transferencia logo apos a primeira chamada: forte indicio de
    // ferramenta errada, nao de pergunta dificil.
    if (row.handoff_at && calls.length <= 2) {
      candidates.push({
        conversationId: row.conversation_id,
        userMessage: row.first_user_message,
        observedTool: firstCall?.name ?? null,
        guess: 'wrong-tool',
        priority: 3,
      });
      continue;
    }

    // Repeticao da mesma ferramenta: quase sempre erro mal formatado
    // que o modelo nao consegue interpretar como definitivo.
    const repeated = calls.filter((call) => call.name === firstCall?.name).length;
    if (repeated >= 3) {
      candidates.push({
        conversationId: row.conversation_id,
        userMessage: row.first_user_message,
        observedTool: firstCall?.name ?? null,
        guess: 'retry-loop',
        priority: 2,
      });
      continue;
    }

    if ((row.validation_errors ?? []).length > 0) {
      candidates.push({
        conversationId: row.conversation_id,
        userMessage: row.first_user_message,
        observedTool: firstCall?.name ?? null,
        guess: 'bad-arguments',
        priority: 2,
      });
    }
  }

  // Prioridade alta primeiro: o revisor tem tempo limitado e cada caso
  // rotulado vira teste permanente, entao a ordem importa mais que o volume.
  return candidates.sort((a, b) => b.priority - a.priority);
}

O ponto de disciplina aqui é que a mineração entrega candidatos, não casos. Quem rotula é uma pessoa, porque decidir qual seria a ferramenta certa naquela conversa é exatamente o julgamento que a suíte vai congelar. Rotulagem automática a partir do próprio comportamento do agente produz uma suíte que confirma o que o sistema já faz, incluindo os erros, que é o pior resultado possível: um teste que passa sempre e não protege nada.

Vale fechar com o desconforto que esse trabalho revela. Uma suíte de regressão de ferramenta não impede que a API interna mude, não impede que o fornecedor renomeie um campo e não impede que a nova versão do modelo escolha diferente. O que ela faz é mover a descoberta desses fatos do canal de atendimento para o pipeline, onde eles custam uma revisão em vez de um incidente. Essa é toda a proposta, e é bem mais do que parece quando o time descobre, pela terceira vez no trimestre, que o agente parou de consultar pedido porque alguém achou que renomear um campo era mudança interna.

FAQ

Perguntas frequentes

Preciso rodar a suíte com o modelo real em todo pull request?

Não, e insistir nisso costuma matar a prática. O caminho que funciona é separar por camada e por custo. O snapshot de contrato, que compara o esquema exposto ao modelo com uma versão gravada, roda em milissegundos, não chama provedor nenhum e deve rodar em todo pull request, porque pega parâmetro obrigatório novo, valor de enumeração removido e campo renomeado sem custo. O teste de mapeamento do retorno contra respostas reais gravadas também roda sempre, pela mesma razão. A suíte que exige o modelo no laço, que mede seleção de ferramenta e preenchimento de argumentos, fica em duas camadas: um conjunto crítico de vinte a quarenta casos em todo pull request que toca ferramenta ou prompt, e o conjunto completo por evento, quando o diff de contrato aponta mudança de descrição, quando o identificador do modelo muda e uma vez por dia no agendado. Barateia muito parar na primeira decisão do modelo em vez de executar a ferramenta e gerar a resposta final: corta a maior parte dos tokens de saída e todo o custo do serviço de destino.

Como evito que a suíte de seleção vire uma fábrica de falso positivo?

Com três decisões concretas. A primeira é comparar apenas os argumentos críticos de cada caso, usando expressão regular ou predicado em vez de igualdade exata do objeto inteiro, porque o modelo preenche campos opcionais de forma legitimamente variável e exigir identidade total gera falha em toda execução até alguém silenciar a suíte. A segunda é separar o hash do esquema de invocação do hash das descrições: ajuste de redação não pode bloquear merge, deve apenas disparar a suíte de seleção, enquanto mudança estrutural bloqueia. A terceira é avaliar por limiar agregado e não caso a caso: defina um piso de acerto de ferramenta, um piso de acerto de argumentos e um teto de chamadas espúrias, e falhe quando a métrica cruzar o limite. Um único caso oscilando não deve derrubar o pipeline, mas uma queda de três pontos na taxa de seleção deve.

De onde tiro os casos de teste sem inventar perguntas artificiais?

Do tráfego real, com quatro veios que rendem muito mais que uma sessão de brainstorm. Primeiro, conversas que terminaram em transferência para humano logo depois da primeira ou segunda chamada de ferramenta, porque nesses casos a ferramenta escolhida quase sempre foi a errada. Segundo, conversas com três ou mais chamadas seguidas da mesma ferramenta, que indicam laço de repetição causado por erro mal formatado que o modelo não consegue interpretar como definitivo. Terceiro, chamadas que falharam na validação de argumentos, que já chegam com o rótulo pronto. Quarto, e o mais valioso porque quase ninguém tem, perguntas que geraram chamada de ferramenta mas deveriam ter sido respondidas direto, que viram os casos negativos e protegem contra descrição larga demais. Uma ressalva importante: a mineração entrega candidatos, não casos prontos. Quem rotula precisa ser uma pessoa, porque rotulagem automática a partir do comportamento do próprio agente produz uma suíte que confirma os erros existentes e passa sempre.

Contrato de ferramenta é código de produção, não detalhe de prompt

Ferramenta de agente falha na fronteira que nenhuma suíte tradicional cobre: entre o teste de API, que valida o serviço, e o eval de prompt, que valida a redação. Congelar o esquema exposto ao modelo com hashes separados para invocação e descoberta, testar seleção com casos rotulados vindos do tráfego real e parando na primeira decisão, incluir casos negativos que provam que o agente não chama ferramenta à toa, tratar o retorno como contrato com respostas reais gravadas e renovadas, e amarrar o portão do pipeline ao tipo de mudança detectada transforma renomeação de campo em revisão de dez minutos em vez de uma semana de conversa errada. Posso mapear os contratos das suas ferramentas, montar o conjunto crítico a partir do seu histórico de conversas e deixar o portão configurado para bloquear o que quebra e apenas avisar o que muda comportamento.