Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

crab-ganglion-harness

O ganglion é o runtime de agente que este projeto escreveu para si mesmo, e o harness que este livro ensina. Esta página o descreve como componente: o que roda dentro do container de um agente, o que esse programa tem permissão de fazer, e como o repositório está organizado.

O que é

Um harness é o programa que roda dentro do container de agente de um membro. Ele sustenta a conversa, chama o modelo, roda as ferramentas que o modelo pedir, e escreve a transcrição em disco. O crab-shell-proxy inicia um container desses por membro e fala com ele; o que acontece lá dentro é assunto do harness.

crab-ganglion-harness é esse programa, escrito em Go. O go.mod dele declara o módulo, uma versão do Go, e mais nada: o harness não tem nenhuma dependência de terceiros, e a coisa toda compila em um único binário estático. Ele serve HTTP com Server-Sent Events nativamente, então o runner do proxy para ele não precisa traduzir protocolo — internal/ganglion/turn.go, no proxy, anota que é por isso que ele não tem análogo do palpite de 500 milissegundos de que o runner do picoclaw precisa para saber quando um turno terminou.

O outro harness é o picoclaw, um projeto de terceiros em que esta stack começou. Ele continua totalmente suportado e está de saída: um agente que não declara a chave harness: recebe agora este aqui, não aquele. Harnesses cobre a comparação; escreva harness: "ganglion" explicitamente em cada agente que você criar, em vez de se apoiar no padrão.

Pelo que ele é responsável

Servir um turno por requisição. A superfície HTTP são duas rotas e nada mais: POST /v1/chat/completions roda um turno, e GET /health responde {"status":"ok"} para a checagem de saúde do proxy. O turno termina quando o handler retorna.

Transmitir o trabalho enquanto ele acontece. Conforme o agente usa suas ferramentas, a narração e o raciocínio chegam por um canal de progresso. A resposta é diferente: um frame é acumulado e classificado quando está completo, e só então enviado de uma vez. O motivo está escrito na especificação, porque parece uma regressão para quem não o conhece. Se o texto de uma iteração é narração ou é a resposta final depende de como o frame termina, e isso não dá para saber antes de o stream acabar — o proxy mediu sete turnos em cento e doze entregando uma resposta inteira no mesmo frame que trazia uma chamada de ferramenta no fim. Emitir de forma otimista e corrigir depois faria a resposta se reescrever visivelmente na tela.

Limitar um turno. O único limite de um turno é quantas vezes ele pode voltar para pegar mais uma ferramenta. Um turno que chega ao teto para e avisa, em vez de falhar em silêncio. Dois lugares definem isso e o primeiro vence: agents.defaults.max_tool_iterations no config.json montado, que é a superfície que um administrador realmente tem, e GANGLION_MAX_ITERATIONS no container, cujo padrão é 12. O harness imprime no boot o número em vigor e de onde ele veio, porque 12 fica igualzinho se foi um operador que definiu, se foi o arquivo, ou se não foi ninguém.

Rodar ferramentas, confinado. A ferramenta que importa é a shell. Antes de rodar um comando, o binário se reexecuta em um modo sandbox, aplica um domínio Landlock, e só então executa o comando. O workspace é leitura e escrita; /usr, /bin e /sbin são leitura e execução; todo o resto, /proc incluído, é negado — e é isso que impede um comando de ler /proc/1/environ e, com ele, a chave de provedor do próprio harness. Se o kernel não conseguir fornecer um domínio Landlock, o harness se recusa a subir em vez de servir turnos sem confinamento.

Outras ferramentas só são registradas quando a configuração as suporta: load_image e set_reasoning_depth estão sempre presentes, web_search e web_fetch aparecem quando há um provedor de busca configurado, generate_image quando há um modelo de imagem, e subagents e research quando os sub-turnos estão habilitados. O que um operador montou por MCP entra por cima. Cada linha do log de boot diz quais delas foram ligadas.

Persistir a conversa. A transcrição é um JSONL só de acréscimo em workspace/sessions, e a janela de contexto é um artefato derivado, separado. Uma conversa que tem transcrição e não tem janela — o estado em que chega toda conversa migrada do picoclaw — tem a janela reconstruída a partir da transcrição, para que o membro não veja o histórico na tela enquanto o agente responde como se a conversa tivesse acabado de começar.

Manter o layout que a stack espera. Tudo vive sob o segmento workspace, porque é ali que o proxy olha de fora do container. Os arquivos de um projeto vão em workspace-<id>, irmão de workspace/, nunca filho dele; o harness migra no boot uma subárvore deixada no caminho antigo. Veja Agentes, workspaces e projetos.

Pelo que ele não é responsável

Ele não inicia, não para e não provisiona nada, nem a si mesmo. Ciclo de vida de container, volumes e a posse deles, materialização de segredos, identidade e autorização no mycelium, o registro de modelos, a API de administração, projetos e tarefas agendadas ficam todos no crab-shell-proxy. A especificação do harness lista isso como permanentemente fora de escopo.

Ele não decide quem pode aprovar uma ação sujeita a aprovação. O harness tem a porta Approver e a suspensão no meio do turno atrás dela, e o primeiro adaptador dela chama de volta o proxy, que é dono da identidade do membro — o harness só pergunta. A outra metade dessa ida e volta não está construída: não existe endpoint de aprovação no proxy, e com GANGLION_APPROVAL_ENDPOINT não definida o laço instala um aprovador que aceita tudo. Trate o fluxo de aprovação como uma costura que existe, não como um recurso que você pode ligar.

Ele não tem diretório .secrets/. As credenciais chegam a este harness como variáveis de ambiente. Um valor que não pode ficar em texto puro pode ser criptografado antes — crab-ganglion encrypt lê o texto puro pela stdin, nunca como argumento, e imprime o valor enc:// para você colar no ambiente do deploy.

Como é construído e testado

Este é o único dos quatro repositórios com um portão de pull request que roda a suíte de testes. .github/workflows/ci.yml roda, em cada pull request e em cada push para main:

gofmt -l . | tee /dev/stderr | (! read)
go vet ./...
go test -race ./...
go build ./...

-race é deliberado, não hábito: o escritor SSE tem dois escritores por construção, o turno e o heartbeat, e uma saída intercalada corromperia um frame que o cliente está no meio de interpretar.

Os mesmos testes rodam uma segunda vez dentro do Dockerfile, antes de o binário ser linkado, então a imagem não pode ser construída sem que eles passem. release.yml constrói com o cache de camadas desligado exatamente por isso — reaproveitar os testes de um cache faria uma execução verde deixar de ser evidência de que eles rodaram para estes bytes.

A imagem de runtime é Alpine, e não distroless, e o Dockerfile explica que isso foi descoberto na prática: distroless não tem /bin/sh, então a ferramenta shell nunca conseguiria funcionar. O container roda com uid 1000, que é o mesmo para o qual o proxy faz chown do volume de cada membro, e escuta na porta 18800.

Toda imagem publicada é endereçada pelo commit que a produziu. Não existe :latest nem :main; release.yml publica ghcr.io/lepistabioinformatics/crab-ganglion:sha-<commit> e nada que venha a ser reconstruído. Essa é uma cicatriz própria desta stack: o EnsureImage do proxy retorna cedo quando um nome resolve localmente, e a imagem do harness não é um serviço do compose, então uma tag reconstruída pode deixar um host rodando bytes antigos indefinidamente sem nada nos logs. Aconteceu uma vez, por três semanas.

A consequência para um deploy é que a referência da imagem é uma decisão que alguém toma. docker-compose.prod.yaml não define CRAB_GANGLION_IMAGE e não fornece padrão, então um deploy de produção tem que nomear um digest ou uma tag sha- explicitamente antes que um agente ganglion possa iniciar. O arquivo compose de desenvolvimento segue o outro caminho: um serviço ganglion-image, só de build, constrói o harness a partir do submódulo e o marca como zombie-crab/crab-ganglion:dev, e o proxy espera por ele. Como esse build roda a suíte de testes do próprio harness, um teste que falha impede a stack de desenvolvimento de subir. Veja Deploy.

Como o código está organizado

O repositório é hexagonal, e o formato é garantido por testes, não por convenção.

cmd/crab-ganglion/main.go   a raiz de composição: o único lugar em que os adaptadores se encontram
internal/domain/            entidades, as portas, política pura — só biblioteca padrão
internal/runtime/           o laço do turno, compactação, sub-agentes
internal/config/            o ambiente e o config.json montado
internal/adapter/           um diretório por adaptador
internal/secret/            o resolvedor de enc://
internal/skillfile/         leitura de arquivos de skill

internal/domain/arch_test.go guarda duas regras. A primeira falha se o pacote de domínio importar qualquer coisa fora da biblioteca padrão, porque, no momento em que o domínio importa um cliente HTTP ou o SDK de um provedor, o laço deixa de ser testável sem eles. A segunda falha se um adaptador importar outro, e é isso que faz de um segundo provedor ou de um segundo ingresso um arquivo novo, e não uma refatoração.

Os adaptadores são agrupados pelo que eles acionam: httpsse é o caminho de entrada; provider/openai e provider/router são o caminho de saída para um modelo; store/jsonl e store/window são a transcrição e o contexto derivado; tool/* é um diretório por ferramenta; approver/proxy, telemetry/otlp, mcp, skills e evolution são o resto. Adicionar uma ferramenta é um arquivo mais uma linha de registro em main.go — não uma mudança no laço, na porta ou no registro.

A especificação de tudo isso vive no repositório do produto, em .specs/features/crab-ganglion-harness/, junto com as notas de design e os identificadores de requisito que os comentários do código citam.

Para onde ir agora

Harnesses compara os dois runtimes e explica o que o proxy faz quando um recurso não está disponível em um deles. Configuração cobre o ambiente que este container lê. Trabalhando na stack tem os comandos.