Você adiciona uma ferramenta ao seu Agent para, por exemplo, buscar posts públicos de uma plataforma. Ela roda em produção por duas semanas sem qualquer problema. Então você muda o valor padrão de limit de 25 para 20 e adiciona um novo valor ao enum de sort. O código mudou, os testes passaram, o merge foi feito.
Três dias depois, começam a surgir erros esporádicos em produção. O modelo chamou a ferramenta com um valor de enum que você removeu na semana anterior; a validação em runtime recusou a chamada, e o stack trace aponta para a camada de dispatch. Você passa meia hora examinando esse código. Não há uma linha errada ali. O problema real está em outro lugar: a assinatura da função mudou, mas a descrição da ferramenta que o modelo lê ficou intacta. O modelo ainda trabalha com o schema antigo, gera chamadas para esse formato antigo e, naturalmente, elas já não encaixam.
Esse é o drift de descrições de ferramentas. Trata-se da classe de bug mais comum — e mais difícil de rastrear — em engenharia de Agents. O motivo é bem específico: o erro e sua causa estão em lugares diferentes. A falha aparece na camada de execução; a origem está em um arquivo JSON que ninguém lembra de abrir. Este texto é sobre eliminar estruturalmente a causa desse bug. Não se trata de “lembrar de manter tudo sincronizado”, mas de organizar o sistema para que não exista uma segunda cópia capaz de divergir.
De onde vem o drift
Na raiz, o problema é simples: você mantém duas fontes de verdade.
A primeira é o código que realmente executa: assinatura da função, validação dos argumentos, valores padrão e restrições de enum. Essa é a parte rígida. Se estiver errada, falha de forma explícita.
A segunda é a descrição da ferramenta lida pelo modelo: name, description e o schema JSON de parameters. Essa parte é flexível. Se estiver errada, nada explode de imediato. O modelo apenas gera uma chamada inválida, que só vai falhar mais abaixo, na camada de execução.
Enquanto uma pessoa precisar manter essas duas partes alinhadas, o drift deixa de ser uma possibilidade e vira uma questão de tempo. Você altera um argumento no código e esquece a descrição. Ou altera a descrição e esquece o código. Ou atualiza ambos, mas os significados continuam desalinhados. Nada disso aparece no instante da mudança. O problema fica à espera de uma chamada do modelo que toque justamente na diferença — quando, duas semanas depois, você já nem lembra mais do que editou. A correção só tem um caminho: transformar duas cópias em uma só.
A declaração é a interface e a única fonte de verdade
A mudança de perspectiva é esta: você não precisa manter aquele JSON de descrição da ferramenta.
A declaração do parser de uma função, junto da docstring, já traz todos os campos necessários para uma descrição de ferramenta. Veja uma declaração simples com argparse:
subreddit = commands.add_parser("subreddit", help="Query a public board's feed")
subreddit.add_argument("subreddit")
subreddit.add_argument(
"--sort",
choices=("hot", "new", "top", "rising", "controversial"),
default="hot",
)
subreddit.add_argument("--limit", type=int, default=25)
O help fornece a descrição curta do comando. choices define a restrição de enum. default informa o valor padrão. type define o tipo do parâmetro, e o argumento posicional é o campo obrigatório. Tudo de que o modelo precisa para chamar a ferramenta está ali: o que o comando faz, quais parâmetros aceita, quais são obrigatórios, os valores do enum e os padrões. E essa mesma declaração é usada pelo runtime para fazer parsing e validar a entrada. Ela não pode se afastar da lógica de execução porque ela própria é a lógica de execução.
Então pare de escrever uma segunda descrição da ferramenta. A postura correta é considerar que esse documento não existe. Há apenas código e, quando for necessária uma descrição, ela é projetada a partir dele. O fluxo é unidirecional: do código para a descrição, nunca o contrário.
Gere todo o catálogo a partir das declarações
Quando a declaração passa a ser a interface, a descrição da ferramenta não deve mais ser escrita à mão. Um gerador deve produzir todas elas.
O trabalho desse gerador é mecânico. Ele percorre cada contexto de plataforma, importa seu parser e converte a lista de ações do argparse em três estruturas imutáveis: Platform, Command e Parameter. Cada Parameter carrega nome, tipo, flag de obrigatoriedade, valores de enum, padrão e texto de ajuda. É um read model da interface, derivado inteiramente do código.
Com esse read model em mãos, qualquer formato de saída vira um produto downstream. Um describe --format json emite a interface completa, legível por máquina, para alimentar a seleção de ferramentas do Agent. Um render_skill() gera um catálogo de capacidades que uma pessoa ou um modelo consegue ler. A quantidade de comandos nesse catálogo não é uma constante digitada manualmente: ela vem de sum(len(platform.commands)), calculado na hora. Hoje, isso resulta em 22 contextos de plataforma e 241 comandos — e nenhum deles foi inserido manualmente no catálogo.
Isso traz uma propriedade bastante confortável. Adicionar uma plataforma significa adicionar um contexto de plataforma, e o catálogo absorve seus comandos automaticamente. Alterar um parâmetro significa editar a declaração do parser, e o enum e o padrão correspondentes no catálogo se atualizam sozinhos. Você nunca mais encontra situações como “criei um comando e esqueci de registrá-lo” ou “mudei um parâmetro, mas o catálogo está desatualizado”, porque o ato de registrar deixa de existir. O catálogo é calculado, não mantido.
(Esse impulso de derivar em vez de manter é o mesmo que aparece ao usar operações de conjunto para decidir o que de fato foi migrado entre linguagens, assunto de este texto sobre migração entre linguagens.)
CI transforma o drift em um X vermelho no commit
A derivação resolve o caso em que um novo comando entra automaticamente no catálogo, mas ainda há uma brecha. Alguém altera uma declaração de parser, esquece de rodar o gerador e não commita o catálogo regenerado. A cópia no repositório volta a ficar desatualizada, e o drift entra pela porta dos fundos.
A última barreira fica no CI, e seu núcleo é uma única asserção:
docs-check:
$(PYTHON) -c 'from pathlib import Path; from reverse.catalog import render_skill; \
path = Path("skill/SKILL.md"); \
assert path.read_text(encoding="utf-8") == render_skill(), \
"skill/SKILL.md is out of sync with the code; run make docs"'
Ela pega o catálogo commitado no repositório e o compara byte a byte com um catálogo regenerado a partir do código atual. Basta um caractere diferente para o CI falhar, com uma mensagem dizendo que o catálogo está desatualizado e que é preciso rodar make docs.
O valor dessa única linha está em mudar o momento em que o drift é encontrado. Antes, ele era um fantasma de runtime: explodia em produção duas semanas depois, com um stack trace apontando para o lugar errado. Agora é um X vermelho no momento do commit. O pull request é bloqueado, o erro informa que o catálogo está desatualizado, e regenerá-lo resolve o problema. O drift deixa de ser “o bug mais difícil de rastrear” e vira um erro de compilação resolvido com um comando. Esse é o ciclo completo de interface como código: a declaração é a fonte, o catálogo de capacidades é o artefato de build, e o CI é a checagem de tipos. Você não escreveria manualmente um artefato de build nem aceitaria que ele divergisse da fonte. Descrições de ferramentas merecem o mesmo tratamento.
O que fixar em código e o que delegar ao modelo
Derivação e CI garantem que a descrição da interface esteja correta. Mas há uma decisão anterior: determinada capacidade deve ser escrita como código fixo ou deixada para o modelo orquestrar no momento? Se essa divisão estiver errada, uma interface precisa não será suficiente.
Vale olhar para as capacidades em três camadas.
Uma primitiva de baixo nível lê um tipo de dado ou executa uma ação clara. A entrada é estável, a saída é estruturada e ela pode ser testada isoladamente. Essa camada é código puro e não consome raciocínio algum. A grande maioria dos 241 comandos fica aqui.
Um workflow determinístico é um processo com ordem forte dentro de uma plataforma, estado compartilhado e uma condição clara de sucesso. Pense em um pipeline criativo, creative-pipeline, que executa em sequência: encontra oportunidades, depois Top Ads, depois matching de criadores, depois um briefing criativo e, por fim, um preflight de geração. A ordem e as dependências entre as etapas são fixas. Essa camada também deve ficar congelada no código, porque, se a ordem já está definida, fazer o modelo planejar tudo de novo a cada execução é mais lento e menos estável. Para marcá-la, basta uma linha: atribuir ao comando set_defaults(_command_level="workflow"). Essa é a única linha desse tipo no codebase, e é ela que faz o catálogo mostrar workflows e primitivas em dois níveis separados.
A orquestração do Agent é a camada de pesquisa entre plataformas, decisões com trade-offs ao vivo e redirecionamentos após uma falha. É essa parte que você realmente deixa para o modelo, porque a próxima consulta depende do que a consulta anterior encontrou — algo que não dá para especificar antecipadamente.
O critério é bastante claro. Se uma capacidade precisa de status estável entre etapas, contexto compartilhado ou efeitos colaterais de geração, congele-a em código. Se envolve expansão de consultas, verificação entre plataformas ou redirecionamento depois de uma falha, deixe-a com o modelo. Os dois erros têm custo. Codificar uma hipótese de pesquisa no cliente é congelar demais: no dia em que a plataforma mudar, você volta a editar código. Entregar ao modelo uma sequência fixa para ele remontar sempre é congelar de menos: você economiza uma decisão do modelo e compra uma pilha de instabilidade.
Deixe o modelo decidir como degradar: seis status de etapa
Para a camada de orquestração tomar decisões, os retornos da camada inferior precisam ser compreensíveis para o modelo. Um booleano opaco de sucesso ou falha não basta. Entregue ao modelo um success: false e tudo o que ele pode fazer é adivinhar o próximo passo.
Por isso, cada etapa de um workflow retorna um status de etapa, não um booleano. São seis: completed, empty, ready, skipped, unavailable e blocked. A informação está justamente nas diferenças entre os estados que não avançaram:
skippedsignifica que o operador desativou essa etapa deliberadamente, por exemplo ao definir o limite de um caminho de coleta como 0. Não é um erro, e o modelo não deve tentar de novo.unavailablesignifica que algo de que a etapa depende está temporariamente indisponível, como uma interface retornando erro ou uma sessão ausente. O modelo pode pular a etapa e seguir em frente, ou solicitar uma nova sessão e voltar depois.blockedsignifica que uma pré-condição não foi atendida, como evidência de pesquisa vazia ou falha no preflight. O modelo não deve forçar a próxima etapa. Deve voltar e completar as evidências.
Veja esse pipeline criativo. Ele avalia separadamente se “o preflight da plataforma está pronto” e se “as evidências de pesquisa estão prontas”, com um ready = platform_ready and research_ready final. Se qualquer um falhar, a etapa de geração retorna blocked com uma lista blockers explicando o que a impede de avançar. E, quando todos os resultados de busca comercial estão vazios, ele simplesmente não envia o job de geração.
Por que esse design beneficia o modelo? Um modelo de orquestração que lê seedance_generation: blocked junto de blockers: [research_evidence_empty] entende que precisa voltar para buscar evidências, em vez de repetir o envio. Ao ler organic_discovery: skipped, entende que se trata da intenção do usuário, não de uma falha, e deixa a etapa em paz. Ao encontrar uma etapa unavailable, sabe que pode degradar o fluxo ao redor dela. Quando você separa “desativado de propósito”, “temporariamente indisponível” e “pré-condição não atendida”, o modelo consegue escolher o caminho de degradação correto. Achate os três em false e até um modelo forte fica girando no mesmo lugar.
O modelo certo para cada camada
A stack acima exige coisas muito diferentes de um modelo em cada camada. (O texto sobre engenharia reversa em quatro estágios apresenta a mesma tabela de quatro níveis no contexto de engenharia reversa; aqui, ela é aplicada à stack de Agent.) Distribua os modelos por camada e você para de desperdiçar capacidade:
Trabalho na stack de Agent | Capacidade necessária | Escolha | model id |
|---|---|---|---|
Carregar no contexto o JSON de | Contexto longo; lê o catálogo inteiro de uma vez | Kimi K3 |
|
Orquestração: ler status de etapa e blockers, decidir degradar, redirecionar ou continuar | Raciocínio forte; toma a decisão certa a partir do status | Claude Opus 5 |
|
Gerar em lote textos de descrição de ferramentas, amigáveis ao modelo, a partir de docstrings | Baixo custo; executa centenas de chamadas com alta concorrência | Claude Sonnet 5 |
|
Atribuição de causa em erros de chamada de ferramenta: ler o erro e a declaração, decidir se é drift ou mudança upstream | Raciocínio intermediário; explica com base em campos específicos | GPT-5.6 Sol |
|
A camada de orquestração é a que merece mais atenção. Ler blocked e skipped para decidir o próximo movimento é o único ponto desse fluxo em que trocar de modelo muda visivelmente o resultado, porque o teste é exatamente a capacidade de tomar a decisão certa a partir de um status. Um modelo mais fraco interpreta skipped como falha e tenta novamente, ou vê blocked e envia mesmo assim. Um modelo de raciocínio forte lê os blockers e redireciona com precisão. É o mesmo tipo de diferença que separa apenas produzir uma hipótese de realmente avaliar se a seção de contraevidências argumenta contra si própria em este texto sobre fingerprinting: qualquer um pode gerar o candidato; a parte difícil é o julgamento.
Você não precisa acreditar apenas na teoria. Teste:
Pegue um retorno real de um dos seus workflows, com seus
stageseblockers, ou fabrique uma respostablockedcomblockers: [research_evidence_empty].Envie essa resposta, mais seu catálogo de capacidades — o JSON de
describe— e uma instrução para decidir a próxima ação paraclaude-opus-5egpt-5.6-sol, separadamente.Observe uma coisa: a próxima ação proposta pelo modelo distingue corretamente
blocked(voltar para buscar evidências),skipped(intenção do usuário, deixar como está) eunavailable(obter uma sessão ou degradar ao redor), ou ele tenta novamente oskippedcomo se fosse uma falha?A proporção de caminhos de degradação corretos é seu critério de escolha. Ela determina se seu Agent fica girando diante de uma falha real ou encontra sozinho uma rota alternativa.
O custo de trocar de modelo é o verdadeiro entrave
Os quatro modelos vêm de três fornecedores, e o custo de troca é especialmente pesado em function calling. Os formatos tools / tool_calls da OpenAI e tool_use / tool_result da Anthropic são diferentes. Se você adota um modelo com melhor capacidade de julgamento na camada de orquestração, precisa reescrever toda a sua camada de dispatch de ferramentas e parsing de erros. Esse é o verdadeiro motivo de a maioria das pessoas acabar presa a um único modelo na orquestração, mesmo quando ele interpreta mal os status de etapa com frequência.
AIReiter remove essa camada. Uma chave, uma interface compatível com OpenAI, os quatro modelos por trás dela, e a troca se resume a alterar o campo model no corpo da requisição.
# Orchestration decision: hand the reasoning tier the catalog plus one blocked workflow response, ask for the next action
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "<describe json> + <stages/blockers response> + decide the next action"}]
}'
# Generate tool-description text in bulk: change the model field, leave the rest
# "model": "claude-sonnet-5"
# Error attribution:
# "model": "gpt-5.6-sol"
Para function calling nativo, basta adicionar um array tools; o protocolo de ferramentas da OpenAI passa por essa interface sem alterações, portanto trocar de modelo continua sendo uma edição de um único campo. Se você já usa o SDK da OpenAI, aponte base_url para https://aireiter.com/api/v1 e não mude mais nada. No SDK da Anthropic, use POST /api/v1/messages com a mesma chave.
Em preço, os modelos Claude saem com 30% de desconto sobre a tabela, os modelos GPT pela metade, e o Kimi K3 está disponível com a mesma chave. Nesta stack, o desconto incide onde importa. Cada avanço da camada de orquestração é mais uma chamada da camada de raciocínio, o que a torna a mais frequente e mais cara de todo o Agent — e o desconto de Claude atua exatamente nela. Gerar em lote descrições de ferramentas a partir de 241 docstrings é trabalho de Sonnet com alta concorrência, também com desconto. Esses dois pontos concentram a maior parte do custo. As chamadas GPT-5.6 para atribuição de erros são bem menos frequentes.
Experimente sem cadastro: faça algumas rodadas manualmente, envie a mesma resposta
blockedpara os dois modelos e veja por conta própria qual deles degrada corretamente antes de conectar um deles à camada de orquestração.
Conclusão
Drift em descrições de ferramentas não se resolve com “lembre-se de sincronizar”. Essa abordagem apenas transforma um defeito estrutural em questão de disciplina pessoal. A solução real é eliminar a estrutura de duas fontes: a declaração do parser, junto da docstring, é a única fonte; o catálogo de capacidades é um artefato de build derivado dela; e uma asserção no CI funciona como checagem de tipos. O drift deixa de ser um fantasma de runtime para virar um X vermelho no commit.
Mas a derivação garante apenas que a descrição esteja correta. Ela não diz nada sobre a divisão de camadas estar certa. Quais capacidades você congela em código e quais deixa o modelo orquestrar, além dos seis status de etapa que permitem ao modelo entender “tentar de novo ou degradar”, são os dois fatores que definem se seu Agent consegue operar sozinho. Nesse stack, o modelo tem dois trabalhos concretos: decidir os trade-offs na camada de orquestração e atribuir a causa quando uma chamada de ferramenta falha. A decisão de congelar uma capacidade e o caminho de degradação a seguir são definidos pelos status de etapa que você projeta e pelo CI que você escreve, não pelo modelo.
É a mesma postura dos textos sobre reconciliação de migração baseada em conjuntos e sobre não construir um Model de resposta unificado: a IA comprime o tempo de uma etapa, e o veredito continua dentro das restrições codificadas por você. Quando tudo passa a rodar sem atrito, resta apenas a troca de modelos — um problema de infraestrutura que uma interface unificada resolve.