Agentes de codificação podem prosperar em uma linguagem que nunca viram?
On this page
Há uma pergunta que retorna toda vez que alguns colegas e eu vemos um agente de codificação fazendo algo impressionante: "ok, mas ele viu milhões de exemplos disso. O que acontece quando não viu?"
Muito do que faz os agentes parecerem inteligentes em React ou Rails é que existe uma montanha de código React e Rails por aí para eles treinarem. Então, eu quis verificar como o agente se sairia no cenário menos favorável que eu conseguisse imaginar: uma linguagem de programação que não está em seu conjunto de treinamento, com nenhuma documentação, em uma base de código que ele não escreveu.
O problema é que toda linguagem que eu conheço é, bem, conhecida, então eu tive que criar uma. Como a linguagem em si foi criada é um experimento separado, e estará em um post separado, mas eu tenho que dizer: o fato de agora ser tão fácil e rápido criar uma linguagem pequena, não pronta para produção, é incrível.
Minha ideia não era apenas criar uma linguagem, eu queria algo majoritariamente desconhecido, mas que ainda assim tivesse a aparência de uma linguagem.
Conheça o Slopersons
Slopersons (sim, eu sei) é uma linguagem pequena, de tipagem dinâmica, com um interpretador escrito em TypeScript. Sua sintaxe é um fluxo da esquerda para a direita: dados à esquerda, etapas à direita, unidos por ->. Blocos são pipes na margem. Aqui está uma amostra:
proc greet name
| name = "" ? "Hello, stranger!" -> out
| "Hello, {name}!"
["World" "" "Planet"] -> array/each name
| name -> greet -> io/print-lineAlgumas coisas que a tornam diferente o suficiente do que um agente já viu antes:
value -> proc argchamaproccomvaluecomo seu primeiro argumento, e um nome simples nunca é uma chamada. Para chamar um procedimento sem argumentos, você o passa via pipe parado:datetime/now -> do.=>vincula um nome, e=>>atualiza um nome que vive fora da lambda atual. Usar o errado é um erro em tempo de execução.- Booleanos são
yesouno
. 12. Existe um valor semelhante a null chamado nothing, mas ele não é considerado falsy.
- Os nomes são kebab-case, então espaços ao redor de operadores são obrigatórios.
a-bé um nome ea - bé uma subtração. - O operador de comparação é
=, e sua contraparte é/=
Ela também vem com as coisas que você esperaria de uma linguagem "real": um REPL, uma biblioteca de testes escrita na própria linguagem, módulos, tratamento de erros, arquivos, JSON, sockets e até um módulo HTTP. Os testes são assim:
"greet" -> test/describe
| "greets people by name" -> test/it
| | "World" -> greet -> test/assert "Hello, World!"And um teste com falha diz exatamente o que aconteceu e onde:
greet
✓ greets people by name
✗ has a fallback for nameless people
expected "Hello, nobody!", got "Hello, stranger!"
at greet.slop:12:17
1 passed, 1 failedMantenha essas mensagens de erro em mente, elas serão importantes mais tarde.
Configurando o experimento
Primeiro, pedi à mesma sessão do agente que estava construindo a linguagem comigo para escrever três apps, cada um com seu próprio conjunto de testes:
- Um registro de usuários, um app de terminal que registra usuários com um nome e uma data de nascimento, os lista e os mantém em um arquivo JSON.
- Um app de notas, um pequeno app web que serve uma página com um formulário para adicionar notas, além de uma API JSON.
- Um rastreador de problemas, um app web muito maior com logins, projetos, problemas que passam por um fluxo de trabalho, comentários e uma API JSON, dividido em camadas de domínio, aplicação, adaptador e web. Escrever este fez o agente adicionar algumas peças que faltavam na linguagem, como hashing de senha e cookies, mas isso é história para o outro post.
Então veio o experimento propriamente dito. Para cada app, iniciei uma sessão nova dentro do ai-jail, um sandbox de SO para agentes de codificação. Por padrão, o agente só pode escrever na pasta do projeto, recebe um diretório home temporário em vez do meu e não tem acesso à rede além da API do modelo. Dessa forma, ele não conseguia procurar na minha máquina (ou na internet) pelo código-fonte do interpretador, seus testes ou sua documentação.
Tudo o que cada agente recebeu foi:
- o executável
slopersonscompilado - o código-fonte do app
- os testes do app
Sem documentação, sem referência da linguagem, sem exemplos além do próprio app. Então, pedi a cada um que adicionasse algumas funcionalidades ao seu app e que documentasse, em um arquivo exploring.md, o que aprendeu sobre a linguagem e como descobriu cada coisa. Essa última parte é o que me permitiu entender como ele chegou a tal conclusão.
Aqui está como ficou meu prompt inicial para o app de gerenciamento de notas:
DO NOT CHECK OTHER FOLDERS, ONLY WORK INSIDE THIS FOLDER, DO NOT FOLLOW ANY OTHER PATH, I WANT YOU TO WORK ONLY WITH WHAT YOU HAVE HERE
You should not check outside how this slopersons program is built
Inside this folder we have an app for notes management that can be run with the ./slopersons program. I want you to add the feature of editing a note. Everything that you learn about the language during this process, add to a file called exploring.md in this folder. You should explain what and how you learnedO primeiro passo do agente
A primeira coisa que ambos os agentes fizeram foi ler o código do app e, então, abrir o REPL para testar a sintaxe que tinham acabado de ler.
Ambos leram o código com muita atenção. As notas do agente do registro começam com uma lista de regras de sintaxe inferidas apenas pela leitura dos três arquivos .slop: ~ inicia um comentário, value -> proc a b significa proc(value, a, b), blocos são | e se aninham como | |, hashmaps não têm vírgulas. Então, tudo o que ele inferiu passou pelo REPL para ser confirmado.
E então ambos independentemente descobriram o mesmo truque: digitar apenas o nome de uma função no REPL informa se ela existe.
$ printf 'array/keepnarray/filtern[1 2 3] -> array/keep x | x > 1n' | ./slopersons --repl
slopersons, Ctrl-D to leave
> -> <proc array/keep>
> error: module `array` has no exported name `filter`
at <repl>:1:1
> -> [2 3]Sem documentação, o REPL tornou-se a documentação. Eles o usaram para mapear boa parte da biblioteca padrão, supondo nomes que esperariam de outras linguagens e verificando cada um. As descobertas do agente têm uma lista de coisas que existem e coisas que não existem (array/filter, array/remove, string/substring, hashmap/contains, regex, nil…), e uma nota de que string/slice recebe três argumentos, não dois. O agente de registro construiu uma tabela com cada expressão que tentou e o que retornou.
O agente rastreador, que veio depois, foi um passo além. Ele supôs que haveria um módulo module, encontrou module/names nele, e string -> module/names entregou a ele cada procedimento em string de uma vez. Suas notas têm uma tabela com toda a biblioteca padrão, construída a partir disso. Há um porém: module/names apenas lista o que está dentro de um módulo que você já conhece. Tenha isso em mente.
Note que eles enviaram as linhas para o REPL (printf ... | ./slopersons --repl) em vez de digitar nele. Esse é um detalhe pequeno, mas é uma maneira muito típica de um agente usar um REPL: uma ideia por linha, repetível e nada escrito no disco.
Experimento 1: o registro de usuários
O agente de registro de usuários foi solicitado a adicionar três funcionalidades, uma de cada vez:
- Filtrar usuários por parte do nome, ignorando maiúsculas e minúsculas
- Remover um usuário por id, garantindo que ids de usuários removidos nunca sejam distribuídos novamente
- Recusar o registro de um nome que já existe, ignorando maiúsculas e minúsculas e os espaços ao redor dele, com uma mensagem que nomeia o usuário com quem ele conflita
A suíte de testes passou de 13 para 25 testes, todos aprovados. E ele não apenas escreveu testes, ele os verificou: copiou o módulo e seus testes para uma pasta temporária, quebrou a cópia de propósito com sed e executou os testes copiados para vê-los falhar. Para fazer isso, ele teve que descobrir como use
resolve módulos. Suas notas dizem que ele resolve ao lado do arquivo que está fazendo o use, não no diretório atual, e o agente estava certo sobre isso. Ele também tropeçou em uma armadilha de precedência que pegaria qualquer um:
{"id": 1} -> hashmap/at "id" = 1Isso falha com key no not found in hashmap, porque os argumentos de um procedimento são expressões completas, então é lido como hashmap/at ("id" = 1). A correção é envolver o pipeline em parênteses antes de comparar, e foi isso que ele escreveu, junto com o motivo.
Depois houve um bug de UX, que ele mesmo introduziu. A primeira versão de "remover por id" continuava pedindo um id até que um válido fosse digitado, então a única saída para um erro de digitação era Ctrl-D, que fecha todo o app. Eu tive que apontar o problema eu mesmo, e o agente corrigiu: pede uma vez, explica por que não conseguiu remover e volta para o menu.
Onde ele errou
Suas primeiras notas diziam que => apenas vincula novos nomes. Isso está errado: => também substitui um nome que já existe no mesmo escopo, e o próprio código do app faz exatamente isso.
O que eu gostei foi o que veio a seguir: um commit posterior, dedicado apenas a documentar exatamente quando => é um erro e quando =>> é necessário, corrigiu a afirmação. Para chegar lá, ele escreveu 45 programinhas, cada um com um único caso, e executou todos em um loop, imprimindo cada saída e código de saída. A seção resultante de suas notas contém as regras de escopo reais: apenas chamadas de procedimento e lambda abrem um escopo, mas blocos ?, while, attempt e recover não. => em um nome de um lambda envolvente é um erro, mas em um nome de nível superior de dentro de um procedimento, ele cria silenciosamente um local. Cada afirmação cita o programa que a prova.
Ele também apontou algo que achou inconsistente: um parâmetro de lambda pode reutilizar um nome de um escopo externo, enquanto =>não consegue. Aquele foi de propósito (parâmetros sempre criam bindings novos), mas é uma observação justa de um iniciante.
Experimento 2: o web app de notas
O agente de notas também adicionou três funcionalidades:
- Editar uma nota, com um formulário em
/notes/:id/editque salva com um POST - Manter as notas em um arquivo JSON entre reinicializações. Ele escreve em um arquivo temporário e o renomeia sobre o real, para que uma falha no meio de um salvamento nunca deixe metade de um arquivo para trás, e um arquivo corrompido faça o app recusar a iniciar em vez de iniciar vazio e sobrescrever tudo no próximo salvamento
- Excluir uma nota, com um POST em vez de um link, para que um crawler ou um prefetch de navegador não possa excluir notas ao segui-lo
Ele moveu a manipulação de arquivos para um módulo store.slop separado, para que as rotas do app continuem sem fazer I/O e possam ser testadas sem tocar no disco. Os testes foram de 21 para 46, todos passando, e aqui também ele quebrou o código de propósito para provar que cada novo teste poderia falhar.
Houve um porém: o sandbox não permitia a abertura de sockets, então ./slopersons main.slop falhou com um erro de permissão. O agente nunca viu o web app funcionando. Ele teve que confiar nos testes, que felizmente alimentam o app com requisições HTTP reais lidas de arquivos de texto.
Onde ele errou
A maioria de seus erros foi capturada pelo executor de testes, e cada um deles é um erro que eu esperaria de um humano aprendendo a linguagem:
- Ele chamou um procedimento pelo seu nome puro. Ele escreveu
needs-text -> outpara um procedimento sem entradas, e o teste falhou, dizendo que a entrada dehashmap/atdeve ser um hashmap, mas recebeu um proc. Ele percebeu que um nome puro é um valor, não uma chamada, e quedatetime/now -> do, já presente no código, era o padrão a seguir - Ele colocou
{id}em um nome de teste. Ele nomeou um teste"GET /notes/{id}/edit"e recebeu umunknown nameerro paraid, que é como ele aprendeu que toda string interpola - Sua primeira correção para ids estava errada. Com exclusões, o antigo
length + 1entregava um id que uma nota existente já possuía. Sua primeira correção foi "maior id restante + 1", que ainda reutiliza um id quando a nota mais recente é a que foi excluída. Suas próprias notas dizem que seus primeiros testes não perceberam isso porque eles apenas excluíram do meio. Eu tive que apontar isso, e então ele mudou para um contador que apenas aumenta, mantido junto com as notas no arquivo JSON, e até migrou arquivos salvos no formato antigo
Experimento 3: o rastreador de problemas
Os dois primeiros apps eram pequenos, então a próxima pergunta era se algo disso se sustentaria em algo maior. O rastreador começou com cerca de 2200 linhas de Slopersons, testes incluídos, distribuídas em 36 arquivos, cinco a sete vezes o tamanho dos outros dois.
Desta vez, perguntei ao agente que escreveu o rastreador o que adicionar a seguir, e escolhi quatro de suas sugestões:
- Dependências de problemas: um problema pode bloquear outros, ciclos são recusados, e um problema bloqueado não pode ser movido para concluído
- Markdown em descrições e comentários, analisado manualmente porque a linguagem não tem regex, e escapado para que ninguém possa inserir HTML em uma página
- Limite de taxa de login e proteção CSRF para os formulários
- Um feed de atividades, filtrado por projeto e por pessoa
Depois que o agente do rastreador terminou de implementar isso, o app cresceu para cerca de 3100 linhas, e ele até pegou o interpretador mentindo! Quando um arquivo de teste falhou ao ser analisado, o executor imprimiu o erro e depois um feliz 12 passed, 0 failed, porque os arquivos após o quebrado nunca foram executados. O código de saída era 1, porém, e o agente verificou isso, então suas notas dizem para não confiar apenas no resumo. Eu reproduzi isso, e sim, era um bug no Slopersons.
Desta vez, ele viu o app rodando
Lembra quando o agente de notas nunca viu seu app rodando? Eu não queria que isso acontecesse de novo. O sandbox ainda não deixa o agente iniciar seu próprio servidor, então eu executei o tracker fora dele, reiniciando sozinho a cada alteração de arquivo (obrigado Node watch-path!), e deixei o agente acessar apenas aquela porta e nada mais.
Com as quatro funcionalidades prontas, o agente escreveu um script com 44 verificações contra o app ao vivo, via HTTP real, e encontrou um bug que nenhum dos 159 testes conseguiu. Os testes usam um relógio falso que começa às 09:00 e avança em minutos inteiros, mas a implementação do relógio real tem milissegundos. O datetime/seconds-between descarta a fração, então uma pessoa bloqueada por senhas erradas demais recebeu um Retry-After de 899 segundos quando restavam 899,5, e um cliente que esperou exatamente esse tempo foi recusado novamente. Ele corrigiu isso e adicionou um teste que move o relógio falso em 250 milissegundos.
O mesmo script também tinha uma verificação errada, procurando por uma mudança que o próprio script nunca fez. Em vez de "corrigir" o app, o agente olhou os dados primeiro e corrigiu a verificação.
Onde ele errou
Lembra do module/names? As notas do agente dizem que a linguagem não tem tipo set nem loop while, então ele escreveu a verificação de ciclo como uma caminhada recursiva, com um hashmap substituindo um set. Ambos existem: há um módulo set, e while é uma palavra-chave. Ele também nunca encontrou os módulos date, boolean e stream.
A causa é o mesmo truque que o tornou tão eficaz. Ele encontrou módulos adivinhando seus nomes, e module/names só consegue listar o que está dentro de um módulo que você já encontrou. Uma palavra-chave como while
não aparece em nenhuma dessas listas. Isso não importou para este app, mas um humano lendo essas notas acreditaria que a linguagem não tem loops, e é assim que nasce uma documentação errada. O REPL responde a todas as perguntas que você faz, mas ele não pode dizer quais perguntas você não fez.Inicialmente, ele também escreveu que as horas eram armazenadas por minuto, mas o teste de fumaça mostrou o contrário, então ele voltou e corrigiu a nota, dizendo explicitamente que sua primeira versão estava errada.
O agente prosperou?
Eu diria que sim, e eu não esperava que fosse tão bem inicialmente.
Todos os três apps receberam cada recurso que pedi, e tudo passa: os testes quase dobraram em um app, mais que dobraram em outro, e foram de 103 para 159 no tracker. Verifiquei um bom número das afirmações nos três arquivos exploring.md contra o interpretador real e, além dos erros que já contei, cada um que verifiquei se sustenta, incluindo os sutis sobre o curto-circuito do and, o not não ser canalizável e como os milissegundos são escritos em uma data.
Onde ele errou, o padrão foi sempre o mesmo: ele assumiu algo com base em outras linguagens, o interpretador disse não, e ele ajustou. É assim que eu também aprendo uma nova linguagem.
O que podemos aprender com isso?
A questão é a seguinte: o agente não precisava conhecer a linguagem. Ele precisava de uma maneira de fazer perguntas e obter respostas honestas. Neste experimento, isso veio de quatro lugares:
- O código existente. A maior parte da sintaxe veio da leitura dos apps. O agente de notas escreveu que o código era pequeno e as funcionalidades (não os detalhes da sintaxe) estavam razoavelmente comentadas, e que a maior parte da sintaxe veio da leitura dos arquivos lado a lado. Uma base de código é documentação, quer você queira ou não, então a maneira como você a escreve está ensinando quem vier a seguir, seja humano ou agente
- Um ciclo de feedback rápido. O REPL e o executor de testes respondiam a cada pergunta em milissegundos, e os agentes os usavam o tempo todo: uma sonda por ideia, várias vezes por funcionalidade
- Mensagens de erro que ensinam. Lembra daquelas mensagens de erro? Quando uma lambda usa
=>em um nome de seu procedimento, o interpretador não apenas falha:
error: `total` already exists in an enclosing scope; use `=>> total` to update it or choose another nameEle diz o que está errado e o que fazer em vez disso. Mensagens como "módulo array não possui nome exportado filter" transformaram a adivinhação em busca. Estou convencido de que boa parte do sucesso aqui se deve ao fato de o interpretador ser específico sobre o que deu errado e onde.
- Ver a coisa real rodando. Testes são tão bons quanto o mundo que eles simulam, e os testes do tracker simulavam um relógio sem milissegundos. Apenas o app ao vivo mostrou isso. E a única vez que o feedback estava errado, o resumo do teste após um erro de parse, o agente percebeu porque também verificou o código de saída. Feedback rápido só ajuda quando é honesto
Nada disso é novidade, e é o que sempre quisemos para humanos que entram em um projeto: código legível, feedback rápido, erros claros e uma maneira de vê-lo rodando. Acontece que agentes precisam das mesmas coisas!
Agora, não quero exagerar: os dois primeiros são apps pequenos, com cerca de 300 e 400 linhas cada, testes incluídos, e mesmo o tracker, com cerca de 3100 linhas, está longe de ser uma base de código grande. Todos os três foram escritos por outro agente que conhecia bem a linguagem, então os agentes tinham códigos bem escritos para aprender. Uma base de código legada grande, com nomes enganosos e sem testes, seria uma história completamente diferente, tanto para humanos quanto para agentes. Além disso, o agente de notas nunca viu seu app rodando, e não tenho certeza se eu lançaria um web app que nunca vi rodando. O agente do tracker viu, e suas notas ainda têm lacunas, como os loops ausentes.
Se você quiser fazer um experimento semelhante, mesmo com apps maiores, você pode usar o Slopersons, ele é open source e está disponível no GitHub: https://github.com/talyssonoc/slopersons.
Encerrando
Comecei isso querendo saber se um agente de codificação conseguiria prosperar em um lugar onde seus dados de treinamento não pudessem ajudá-lo, e conseguiu, fazendo o que bons desenvolvedores fazem: lendo o código ao redor, fazendo perguntas pequenas, verificando suas suposições e escrevendo o que aprendeu. E ele deixou notas sobre a linguagem das quais um iniciante poderia realmente aprender, escritas inteiramente de fora.
Uma lição legal com a qual quero encerrar o post é: Um agente sem dados de treinamento ainda é um agente com um loop de feedback.
We want to work with you. Check out our Services page!

