Pular para o conteúdo
Todos os artigos

O que torna um código fácil para um agente de IA mudar?

Mais semântica explícita, menos representação: como ligar specs ao código faz o agente de IA navegar pela base por intenção.

Se agentes de IA vão escrever e manter boa parte do código da sua empresa, a pergunta mais importante deixou de ser qual linguagem o modelo gera melhor a partir de um prompt. A pergunta é outra: o que torna um sistema fácil para um agente entender, modificar e verificar?

Eu passei mais de 20 anos escolhendo linguagem e framework pelos critérios de sempre: produtividade, performance, ecossistema, contratação, complexidade operacional, manutenção. Este ano entrou um critério novo nessa lista, e ele mudou a forma como eu desenho sistemas. Um agente trabalhando em uma aplicação real precisa achar os arquivos certos, recuperar a intenção da arquitetura, entender as invariantes, mudar vários módulos de forma coordenada, rodar os testes, ler as falhas e repetir isso centenas de vezes. Contexto virou recurso de engenharia. Cada ambiguidade na arquitetura tem custo, e cada representação duplicada de um conceito é mais um lugar para as coisas divergirem.

O princípio que ficou disso está na primeira linha do AGENTS.md do repositório do Welaborate, o app de gestão de projetos que estou construindo quase inteiramente com agentes: maximizar semântica explícita e minimizar o overhead de representação e de arquitetura. Nesta edição eu conto o que isso quer dizer na prática, por que me levou a Elixir e Phoenix nesse produto, e como ligamos as specs do OpenSpec a funções e testes para o agente navegar por intenção, e não por grep.

Densidade semântica

Dois conceitos deixaram isso concreto. Semântica explícita é o quanto o código torna visível o que os valores significam, quais estados são válidos, como as transições acontecem, quais falhas são esperadas, quais efeitos colaterais existem e quem é dono de cada comportamento. Overhead de representação é quantos artefatos o sistema precisa para expressar esse comportamento: módulos, DTOs, schemas, interfaces, tipos de cliente, componentes de infraestrutura. A razão entre os dois eu passei a chamar de densidade semântica.

Isso muda uma regra antiga. Linhas de código sempre foram uma métrica ruim de qualidade, e continuam sendo. Mas quando quem mantém é um agente, volume vira superfície de busca. Uma funcionalidade espalhada em quinze módulos e duas linguagens obriga o agente a descobrir, carregar e raciocinar sobre os quinze antes de mudar qualquer coisa com segurança. Mais busca, mais chance de esquecer um. Mais representações, mais chance de inconsistência. O alvo é menos representação por unidade de comportamento de domínio, não menos linhas.

O outro lado é a entropia de soluções. Um modelo conhece Prisma, TypeORM, Drizzle, MikroORM e Sequelize. Conhece várias filas, várias bibliotecas de validação, várias formas de estruturar um NestJS. Para um time humano, essa flexibilidade é útil. Para um agente, cada escolha legítima é mais uma decisão que ele toma sem você, e três modelos diferentes vão tomar de três formas diferentes.

Por que Elixir e Phoenix

A objeção justa é que TypeScript tem um sistema de tipos maduro e Elixir historicamente não tinha. Verificação forte importa, e eu não vou fingir o contrário. Mas verificação é uma de pelo menos quatro dimensões que o agente enfrenta: quantas formas razoáveis existem de implementar algo, quanta representação expressa isso, quanto do repositório precisa ser entendido e com que facilidade a ferramenta prova que a implementação está errada. TypeScript é ótimo na quarta. Elixir e Phoenix me interessam pelas três primeiras (e o Elixir 1.20 já infere tipos e faz checagem gradual sem anotações, então essa distância diminuiu).

E tem a convenção. Em TypeScript eu consigo criar um tipo Result elegante e exigir em todo lugar. O próximo código TypeScript respeitável usa exceções, o seguinte devolve undefined, o outro usa neverthrow. A linguagem permite todos. Em Elixir, {:ok, valor} e {:error, motivo} não são só possíveis, são o que todo mundo escreve. A melhor abstração para um agente muitas vezes não é a mais expressiva. É aquela em que o ecossistema já convergiu sobre como usar.

Uma aplicação Phoenix padrão já tem uma resposta convencional para as perguntas que o agente teria que decidir sozinho: lógica de domínio em contexts, persistência em Ecto, validação em changesets e funções de domínio, broadcast transitório em PubSub, trabalho assíncrono durável em Oban, ciclo de vida em supervisors, interface interativa em LiveView. O Phoenix 1.8 foi longe o bastante nisso para gerar um AGENTS.md por padrão em todo app novo, justamente para evitar erros comuns dos modelos em Elixir. Autores de framework já tratam o agente como mais um consumidor de convenções.

OTP entra como compressão semântica: "se esse worker cair, reinicia sem derrubar os irmãos" é uma árvore de supervisão e uma estratégia de restart. Código pequeno, semântica de runtime grande e conhecida. LiveView faz o mesmo do lado da representação. Em uma arquitetura API mais React, um conceito de domínio aparece como migration, entidade, service, DTO, endpoint, serializer, schema OpenAPI, tipo de cliente gerado, hook de query, estado no cliente, formulário, componente e duas suítes de teste. Com LiveView o caminho é spec, domínio em Elixir, LiveView, HTML. (JavaScript não está proibido; o que eu evito é uma segunda arquitetura de aplicação que o navegador não pediu.)

Rust maximiza semântica explícita, mas para "criar um time" ou "remover um membro" o borrow checker é overhead sem relação com o produto. Go foi a comparação mais difícil, e provavelmente é a alternativa mais forte para serviços de backend autônomos, mas troca densidade por simplicidade e não traz o modelo integrado de ciclo de vida do OTP. Rails, onde eu passei boa parte da carreira, tem densidade altíssima e convenção forte; para um CRUD de negócio, Rails com Hotwire talvez seja um dos melhores stacks para desenvolvimento com agentes. Fui de Phoenix porque esse produto vai crescer para colaboração em tempo real e trabalho em background, e eu prefiro não trocar de modelo de programação quando isso acontecer.

Ligando spec a código sem criar outra fonte de verdade

Essa é a parte que eu gostaria que você copiasse.

O Welaborate é desenvolvido com OpenSpec, da Fission AI. A intenção de produto começa em um PRD, vira uma proposta, depois specs de comportamento, um documento de design e tarefas, depois implementação e verificação, e por fim a mudança é arquivada e seus deltas entram na spec base. A spec descreve o que o sistema faz, não como.

Eu gostei da ideia de proveniência do Fabio Akita (ele publicou em fevereiro um experimento mental sobre como seria programar se o código fosse desenhado para agentes, e não para humanos), mas não queria um banco semântico nem um índice gerado para ter isso. Então cada requisito ganha um ID estável com prefixo de domínio, e cada cenário estende o ID do seu requisito. Um exemplo real da capability de times (as specs são escritas em inglês):

### Requirement: Remove a member [ACC-TEAM-R003]

Owners and admins SHALL be able to remove any member except the owner and
except themselves. Removal MUST revoke the person's access to the team
immediately and MUST NOT delete anything they created...

#### Scenario: Removed person loses access on the next request [ACC-TEAM-R003-S02]

- **WHEN** a removed person who is still signed in makes their next request in that team
- **THEN** it is refused with the message "you are no longer a member of <Team>"

A operação pública de domínio carrega o mesmo ID nos metadados de documentação, e o typespec declara os resultados que o agente teria que cavar para descobrir:

@doc openspec: ["ACC-TEAM-R003", "ACC-INV-R005"]
@spec remove_member(Scope.t(), Ecto.UUID.t()) ::
        {:ok, Membership.t()}
        | {:error, member_management_error() | :cannot_remove_self}
def remove_member(%Scope{} = scope, membership_id) do

E o cenário ganha um teste executável:

@tag openspec: "ACC-TEAM-R003-S02"
test "removed person is refused on the next request" do

O documento de design de cada mudança fecha o ciclo com uma tabela de rastreabilidade: ID, operação, teste que verifica. É só isso: Markdown, atributos de módulo e tags do ExUnit. Nada de banco de grafos, manifesto em YAML ou linguagem de spec especial. Hoje o repositório tem três capabilities (contas, times e convites), 25 requisitos, 108 cenários e cerca de 170 testes marcados com cenário, em mais ou menos 8 mil linhas de aplicação e 7 mil de testes.

O que isso compra é um eixo de navegação. O AGENTS.md manda o agente ir do ID do requisito para o context, do context para a operação pública, dela para os testes marcados, e só então para os internos. Em vez de "quais desses 200 módulos mexem com remoção de membro", a pergunta vira "o que implementa ACC-TEAM-R003", e a resposta é uma função e meia dúzia de testes.

O agente fica no fim da ordem de autoridade

Cada modelo tem preferências. Um adiciona uma camada de service, outro puxa uma dependência nova, outro usa GenServer onde uma tabela bastava, outro enterra regra de negócio no handle_event de uma LiveView. Se GPT, Claude e GLM podem mexer no mesmo repositório, o repositório precisa ter mais autoridade do que qualquer um deles.

Por isso o AGENTS.md define uma ordem: a instrução explícita, depois as specs aprovadas do OpenSpec, depois o design da mudança ativa, depois as regras de arquitetura e ADRs, depois os PRDs como intenção, depois o código existente, e só então o julgamento do agente. Código existente não sobrepõe spec aprovada. E o arquivo é escrito como restrição, não como preferência de estilo: mutação passa por API de context; LiveView é adaptador, não dona de lógica; falha esperada é tupla com tag; estado durável fica no PostgreSQL e nunca só em um GenServer; job durável é Oban e broadcast efêmero é PubSub; infraestrutura nova precisa de requisito demonstrado em design ou ADR; nada de Redis, Kafka ou RabbitMQ porque outro ecossistema usa; IDs são permanentes; implementação é test-first, um grupo de tarefas por vez. (O Oban nem está instalado ainda. A regra diz para adicionar dentro de uma mudança aprovada que precise de jobs duráveis, não antes.)

O objetivo é reduzir o quanto o agente precisa inferir. Qual ORM, qual fila, onde isso mora, como funciona erro aqui, onde está o estado durável: quando o ambiente tem uma resposta convencional para cada uma, esse raciocínio some e o orçamento vai para o requisito de verdade.

O que isso não prova

Eu não rodei um benchmark mostrando que uma implementação em Phoenix gasta menos tokens ou entrega menos defeitos do que uma em NestJS. Isso exigiria mudanças controladas em vários passos, vários modelos, repetições e specs iguais. O argumento é de arquitetura: menos entropia de soluções, mais densidade semântica, menos superfície de busca e contratos explícitos devem ajudar agentes. É plausível o suficiente para eu construir em cima, e deve ser medido.

A lição também não é "use Elixir". Para um storage engine, Rust. Para serviços pequenos e independentes, Go. Para um CRUD de negócio, Rails é dificílimo de bater. A lição geral é o critério: avalie linguagem e framework por quanta informação semântica relevante eles expõem para o agente em relação a quanta representação o agente precisa manipular. Esse critério quase não importava quando humanos escreviam todo o código.

Passamos décadas otimizando software para a cognição humana: nomes, módulos, padrões de projeto, navegação na IDE, code review. Tudo isso existe porque a atenção humana é limitada. Agentes têm limites diferentes: contexto finito, busca imperfeita, custo por chamada de ferramenta e a tendência de escolher um padrão razoável que não é o que o código usa. Repito: a disciplina de engenharia continua a mesma. O que mudou foi quem consome a arquitetura.

Se o seu time está adotando agentes e o código está ficando mais difícil de revisar, e não mais fácil, o problema costuma estar no ambiente, não no modelo. Entre em contato direto comigo ou acesse os links no primeiro comentário e agendamos uma call de diagnóstico, sem custo.

Fontes