Existe um momento comum quando começamos a usar agentes de código em tarefas reais. O agente recebe a tarefa, trabalha por alguns minutos e devolve uma alteração que compila. Os testes passam. À primeira vista, parece tudo certo.

Então você abre o diff e percebe: a regra de negócio foi parar no controller, a injeção de dependência foi feita por campo, apesar da convenção do projeto, e um catch (Exception e) esconde uma falha que deveria ser tratada. Você corrige, pede novamente e, na próxima tarefa, o agente repete parte dos mesmos erros.

Nem sempre a causa é a mesma. A instrução pode estar ambígua, o contexto pode estar incompleto, o modelo pode tomar uma decisão ruim, ou o projeto pode não ter uma verificação que detecte o desvio. Trocar de modelo pode ajudar, mas não resolve sozinho a falta de critérios claros nem a ausência de feedback.

É nesse espaço que entra a Harness Engineering: projetar o ambiente, as ferramentas e os controles que ajudam um agente a trabalhar dentro das regras do projeto e a perceber quando uma alteração não passou nas verificações. Quando vários agentes trabalham em tarefas coordenadas, entra a orquestração.

Vou explicar os conceitos, aplicá-los a um serviço de contas com Java 25 e Spring Boot 4 e apresentar um mini-harness didático. Os exemplos ajudam a entender a arquitetura, mas não devem ser confundidos com uma plataforma de execução pronta para produção.

1. O que é, afinal, um agente de código?

Um modelo de linguagem (o “cérebro” por trás do ChatGPT (Codex), do Claude e de similares) sozinho só faz uma coisa: recebe texto e devolve texto. Ele não abre arquivos, não roda testes, não consulta o seu repositório.

Um agente combina um modelo com um loop de execução, ferramentas e um histórico de interação. Funciona em um ciclo simples:

    A [Recebe a tarefa] --> B [Pensa no próximo passo]
    B --> C {Precisa de uma ferramenta?}
    C -- Sim --> D [Executa: lê arquivo, edita, roda testes]
    D --> E [Observa o resultado]
    E --> B
    C -- Não --> F [Entrega a resposta final]

Imagine que alguém receba apenas o ticket “implementar transferência entre contas” em um repositório que nunca viu. Sem conhecer as camadas, os padrões de erro, os comandos de teste e as partes protegidas, a pessoa pode entregar algo que compila e ainda assim não respeita o sistema. Um agente enfrenta o mesmo tipo de lacuna: precisa de contexto, ferramentas delimitadas e verificações que revelem os desvios antes da integração.

2. Harness: o ambiente que torna o trabalho verificável

Harness é um termo em inglês que aparece na literatura de agentes de código. Traduzir a palavra ao pé da letra não ajuda muito neste contexto, vale mais entender o que ela representa na prática.

Imagine que uma equipe precisa entregar uma nova API. A equipe recebe as convenções do repositório, exemplos de implementação, comandos de build, testes automatizados, regras de arquitetura e permissões compatíveis com a tarefa. Ao abrir um pull request, o pipeline verifica se a alteração compila, se os testes passam e se as regras combinadas continuam válidas. Se alguma checagem falhar, o resultado aponta o problema antes que a mudança seja integrada.

Um agente de código precisa de controles equivalentes. O harness reúne as instruções, o contexto, as ferramentas, as permissões, as verificações e o ciclo de feedback que envolvem o agente. Em vez de depender apenas de um prompt bem escrito, projetamos um processo no qual os erros comuns ficam mais fáceis de detectar e corrigir.

Uma fórmula útil, embora simplificada, é:

Agente de código = modelo + loop de execução + ferramentas + contexto + controles

O modelo gera decisões e conteúdo. O harness determina quais informações ele recebe, quais ações pode solicitar, como elas são executadas e quais evidências precisam existir antes de considerar o trabalho concluído. Isso não torna o agente infalível, reduz ambiguidades e aumenta a chance de detectar problemas cedo.

A expressão Harness Engineering ganhou visibilidade em 2026, especialmente depois do relato da OpenAI sobre uma equipe que construiu software em um fluxo centrado em agentes e do artigo de Martin Fowler que organizou o tema em guias e sensores. As práticas subjacentes (testes, automação, limites arquiteturais e revisão) são anteriores ao nome.

Prompt, contexto e harness: camadas complementares

CamadaFocoPergunta que responde
Prompt EngineeringA linguagem“Como peço isso da melhor forma?”
Context EngineeringA informação“O que o modelo precisa saber para decidir bem?”
Harness EngineeringO ambiente“Como garanto que o agente acerte, e perceba quando errou?”

Esses conceitos coexistem, não são etapas históricas em que uma substitui a outra. O prompt define o pedido imediato, o contexto fornece as informações necessárias para decidir e o harness organiza o ambiente de execução, as restrições e o feedback. Um contexto excelente ainda pode produzir uma resposta errada, por isso são necessárias verificações independentes.

O mapa do harness

MindMap
  root((Harness))
    Guias
      AGENTS.md
      Skills
      Documentação do projeto
      Exemplos de código
    Sensores
      Testes automatizados
      Testes de arquitetura
      Linters e formatadores
      Revisão por outro agente
    Ferramentas
      Ler e escrever arquivos
      Build e testes
      MCP e integrações
    Segurança
      Sandbox
      Permissões
      Hooks bloqueantes
      Limite de passos
    Orquestração
      Sub-agentes
      Paralelismo
      Avaliador

3. Guias e sensores: o vocabulário que organiza tudo

Uma forma útil de pensar nos componentes de um harness foi apresentada por Martin Fowler. É um modelo mental, não uma taxonomia universal: nele, os controles são vistos como guias ou sensores.

E cada um pode ser de dois tipos:

Guia (antes)Sensor (depois)
ComputacionalScripts de bootstrap, templates, geradores de códigoCompilador, testes, ArchUnit, Spotless
InferencialAGENTS.md, skills, documentaçãoAgente revisor, “LLM como juiz”

Regra prática: sempre que uma regra importante puder ser verificada de forma determinística, automatize a checagem. Escrever “não use injeção por campo” no AGENTS.md comunica uma convenção, um teste de arquitetura que falha quando ela é violada oferece um sinal verificável. Ainda assim, uma checagem automatizada não é uma barreira inviolável se o agente puder apagá-la ou alterar o pipeline: proteja também os arquivos de controle e exija revisão no processo de integração.

4. Mão na massa! Aplicando controles a um projeto Spring Boot 4

Vamos usar um serviço de contas com Arquitetura Hexagonal. O objetivo é reduzir a supervisão manual nas tarefas repetitivas, sem confundir automação com autonomia irrestrita: o ambiente detecta determinados desvios, fornece feedback e bloqueia ações específicas, enquanto decisões de negócio e mudanças de alto risco continuam exigindo revisão humana.

A estrutura do projeto, com os arquivos do harness destacados:

contas/
├── AGENTS.md                          # guia inferencial: as regras da casa
├── CLAUDE.md                          # importa o AGENTS.md (para o Claude Code)
├── .claude/
│   ├── settings.json                  # hooks: sensores automáticos
│   └── agents/
│       └── revisor-arquitetura.md     # sub-agente revisor
├── harness/
│   ├── verify.sh                      # sensor computacional único
│   ├── bloquear-arquivos-sensiveis.sh # guarda de segurança
├── pom.xml
└── src/
    ├── main/java/com/exemplo/contas/
    │   ├── dominio/                   # Java puro, sem framework
    │   ├── aplicacao/
    │   │   └── porta/{entrada,saida}/
    │   └── adaptador/
    │       ├── web/
    │       └── persistencia/
    └── test/java/com/exemplo/contas/
        └── ArquiteturaHexagonalTest.java

Os nomes de arquivos de configuração (como .claude/settings.json) são específicos do Claude Code. Outras ferramentas podem oferecer recursos equivalentes, mas os formatos e os eventos disponíveis variam. Confira a documentação da ferramenta que você usa.

4.1 O guia: AGENTS.md

O AGENTS.md pode funcionar como manual do projeto quando a ferramenta usada reconhece esse arquivo. O suporte não é universal: no Claude Code, por exemplo, CLAUDE.md é o arquivo nativo de instruções e pode importar AGENTS.md. Duas regras ajudam a manter essas instruções úteis: seja objetivo e aponte comandos exatos, em vez de escrever intenções vagas.

# Serviço de Contas
Serviço de contas e transferências. Java 25, Spring Boot 4.1, Maven, PostgreSQL.
## Comandos
- Verificação rápida (rode a cada mudança): `./harness/verify.sh quick`
- Verificação completa (rode antes de concluir): `./harness/verify.sh full`
- Nunca finalize uma tarefa com a verificação completa falhando.
## Arquitetura (Hexagonal)
- `dominio`: Java puro. Proibido importar Spring, Jakarta ou Jackson.
- `aplicacao`: casos de uso. Depende só de `dominio` e das portas.
- `adaptador`: web e persistência. Adaptadores não se conhecem entre si.
- Uma dependência nova entre camadas é quase sempre um erro de desenho.
  Revise a direção da seta antes de continuar.
## Convenções
- Injeção de dependência sempre por construtor. Nunca `@Autowired` em campo.
- Objetos de valor e DTOs são `record`.
- Resultados esperados de negócio usam tipos selados (`sealed`), não exceções.
- Dinheiro é `BigDecimal`. Nunca `double`.
- Um teste novo para cada comportamento novo.
## Proibido sem autorização humana
- Editar `application-prod.yml`, arquivos `.env` ou a pasta `harness/`.
- Adicionar dependência ao `pom.xml`.
- Desabilitar ou apagar testes.

A seção de convenções diz o que fazer, a de restrições delimita o que exige autorização humana. Mas texto sozinho não bloqueia uma ação. Para tornar uma restrição efetiva, combine instruções com hooks, permissões da ferramenta, proteção no Git e, quando necessário, isolamento em contêiner sem credenciais sensíveis.

Como o Claude Code lê o arquivo CLAUDE.md, um truque simples para manter uma fonte única da verdade é fazer o CLAUDE.md conter apenas a linha:

@AGENTS.md

4.2 O sensor de arquitetura: ArchUnit

O ArchUnit transforma regras de arquitetura em testes comuns de JUnit. Dependência no pom.xml:

<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit-junit5</artifactId>
    <version>1.4.1</version>
    <scope>test</scope>
</dependency>

E as regras do AGENTS.md convertidas em lei:

package com.exemplo.contas;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import org.springframework.beans.factory.annotation.Autowired;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noFields;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
@AnalyzeClasses(
        packages = "com.exemplo.contas",
        importOptions = ImportOption.DoNotIncludeTests.class)
class ArquiteturaHexagonalTest {
    @ArchTest
    static final ArchRule dominio_nao_depende_de_framework =
            noClasses().that().resideInAPackage("..dominio..")
                    .should().dependOnClassesThat().resideInAnyPackage(
                            "org.springframework..", "jakarta..",
                            "tools.jackson..", "com.fasterxml..")
                    .because("O domínio é Java puro. Se precisar de algo do framework, "
                            + "crie uma porta em aplicacao.porta e implemente no adaptador.");
    @ArchTest
    static final ArchRule aplicacao_nao_conhece_adaptadores =
            noClasses().that().resideInAPackage("..aplicacao..")
                    .should().dependOnClassesThat().resideInAPackage("..adaptador..")
                    .because("A seta de dependência aponta sempre para dentro: "
                            + "adaptador -> aplicacao -> dominio. Use uma porta de saída.");
    @ArchTest
    static final ArchRule adaptadores_nao_se_conhecem =
            slices().matching("..adaptador.(*)..")
                    .should().notDependOnEachOther()
                    .because("Web não fala direto com persistência. "
                            + "A conversa passa pelo caso de uso.");
    @ArchTest
    static final ArchRule sem_injecao_por_campo =
            noFields().should().beAnnotatedWith(Autowired.class)
                    .because("Use injeção por construtor.");
}

Um detalhe que faz toda a diferença: a mensagem do because(...) é lida pelo agente. Quando o teste falha, o agente recebe esse texto como retorno. Por isso, escreva essas mensagens como instruções de correção e não como reclamações. Elas são, ao mesmo tempo, sensor e guia: dizem o que deu errado e para onde ir.

4.3 Um único ponto de verificação: harness/verify.sh

É útil oferecer comandos curtos e previsíveis para o agente. O script a seguir pressupõe que o plugin do Spotless e o teste ArquiteturaHexagonalTest já estejam configurados no pom.xml.

#!/usr/bin/env bash
# harness/verify.sh
set -euo pipefail
cd "$(dirname "$0")/.."
modo="${1:-quick}"
case "$modo" in
  quick)
    ./mvnw -q spotless:check
    ./mvnw -q -Dtest=ArquiteturaHexagonalTest \
      -Dsurefire.failIfNoSpecifiedTests=false test
    ;;
  full)
    ./mvnw -q spotless:check verify
    ;;
  *)
    echo "Uso: verify.sh [quick|full]" >&2
    exit 64
    ;;
esac

O modo quick roda verificações menores durante o trabalho, o full executa a verificação completa antes da entrega. Aqui usamos spotless:check, que apenas verifica a formatação. Deixar spotless:apply fora desse ciclo evita que a própria verificação reescreva arquivos a cada edição. Se quiser formatar automaticamente, rode esse comando como uma etapa explícita.

4.4 Hooks: sensores que disparam sozinhos

De nada adianta um sensor se ele nunca é executado. Hooks permitem rodar comandos em pontos específicos do ciclo de vida do Claude Code. O exemplo usa PreToolUse para bloquear tentativas de editar arquivos protegidos e PostToolUse para rodar verificações depois de uma edição bem-sucedida:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/harness/bloquear-arquivos-sensiveis.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/harness/verify.sh quick 1>&2 || exit 2"
          }
        ]
      }
    ]
  }
}

A diferença entre os eventos é importante. PreToolUse acontece antes da ação; sair com código 2 bloqueia a chamada e mostra a mensagem escrita em stderr. PostToolUse acontece depois que a ferramenta já executou. Se a verificação falhar, o stderr é mostrado ao Claude para que ele tente corrigir o problema, mas a edição original não é desfeita. Esse é um ciclo de feedback, não uma transação com rollback. Confirme o comportamento dos códigos de saída e dos hooks na versão da ferramenta em uso, pois esses detalhes pertencem ao ciclo de vida específico do Claude Code.

A configuração abaixo bloqueia os arquivos protegidos quando a alteração passa pelas ferramentas Edit ou Write do Claude Code:

#!/usr/bin/env bash
# harness/bloquear-arquivos-sensiveis.sh
set -euo pipefail
entrada="$(cat)"
caminho="$(jq -r '.tool_input.file_path // .tool_input.path // empty' <<< "$entrada")"
case "$caminho" in
  .env|.env.*|*/.env|*/.env.*|application-prod.yml|*/application-prod.yml|harness/*|*/harness/*)
    echo "BLOQUEADO: '$caminho' é protegido. Peça autorização humana." >&2
    exit 2
    ;;
esac
exit 0

O script depende do jq e precisa ter permissão de execução (chmod +x harness/bloquear-arquivos-sensiveis.sh). Ajuste a lista de padrões ao repositório e teste casos de borda, nomes como .env.example podem exigir uma política diferente.

Limite de segurança: esse hook não bloqueia a mesma alteração se ela for feita por outro caminho, por exemplo um comando Bash que redirecione conteúdo para um arquivo. O matcher cobre apenas Edit e Write. Portanto, use permissões de ferramenta ou uma lista segura de comandos, mantenha harness/ protegido no Git e execute agentes em um ambiente isolado sem segredos sempre que houver risco real. Hooks ajudam, mas não substituem controles no sistema operacional.

4.5 Um sensor inferencial: o subagente revisor

Nem tudo vira teste. “Esse código está legível?” ou “essa abstração faz sentido?” são julgamentos. Para isso, criamos um segundo agente, especialista e com permissões reduzidas, em .claude/agents/revisor-arquitetura.md:

---
name: revisor-arquitetura
description: Revisa mudanças de código quanto a arquitetura hexagonal, legibilidade e testes. Use proativamente depois de implementar qualquer funcionalidade.
tools: Read, Grep, Glob
---
Você é um revisor sênior de Java e Spring. Você NÃO edita código nem executa comandos; apenas revisa o diff fornecido na tarefa.
1. Analise as alterações e identifique os arquivos afetados.
2. Verifique: a regra de negócio está no domínio? Há lógica em controllers?
   Os testes cobrem o comportamento novo, inclusive casos de borda?
3. Responda neste formato:
   - APROVADO ou REPROVADO
   - Lista curta de problemas, cada um com arquivo, linha e sugestão objetiva.

O subagente não recebe ferramentas de escrita nem shell. Isso importa porque permitir Bash irrestrito também pode permitir modificar arquivos por comandos de terminal. O diff deve ser fornecido na tarefa que inicia a revisão, mantenha o ambiente do revisor sem permissões de escrita sempre que possível.

4.6 Como isso funciona na prática

Imagine a tarefa: “Implemente a transferência entre contas.” O agente escreve o caso de uso e, na pressa, importa a classe do adaptador web dentro da camada de aplicação. Ao salvar o arquivo, o hook dispara e o agente recebe, de volta, algo como (resumido):

Architecture Violation: Rule 'no classes that reside in a package '..aplicacao..'
should depend on classes that reside in a package '..adaptador..', because A seta de
dependência aponta sempre para dentro: adaptador -> aplicacao -> dominio.
Use uma porta de saída.' was violated (1 times):
Class <com.exemplo.contas.aplicacao.TransferenciaService> depends on
<com.exemplo.contas.adaptador.persistencia.ContaJpaRepository>

O agente pode usar o diagnóstico para corrigir o desenho: por exemplo, criar a porta ContaRepositorio em aplicacao.porta.saida, mover a implementação para o adaptador e executar as verificações novamente. Em um caso simples isso pode acontecer sem intervenção humana, mas não é garantido, o agente pode interpretar mal a mensagem ou introduzir outro problema. Para o domínio, algo como:

package com.exemplo.contas.dominio;
import java.math.BigDecimal;
import java.util.Objects;
import java.util.UUID;
public record Conta(UUID id, String titular, BigDecimal saldo) {
    public Conta {
        Objects.requireNonNull(id);
        Objects.requireNonNull(titular);
        Objects.requireNonNull(saldo);
        if (saldo.signum() < 0) {
            throw new IllegalArgumentException("Saldo inicial não pode ser negativo");
        }
    }
    public Conta debitar(BigDecimal valor) {
        validarValor(valor);
        if (saldo.compareTo(valor) < 0) {
            throw new SaldoInsuficienteException(id);
        }
        return new Conta(id, titular, saldo.subtract(valor));
    }
    public Conta creditar(BigDecimal valor) {
        validarValor(valor);
        return new Conta(id, titular, saldo.add(valor));
    }
    private static void validarValor(BigDecimal valor) {
        if (valor == null || valor.signum() <= 0) {
            throw new IllegalArgumentException("Valor deve ser positivo");
        }
    }
}

O ponto não é esse código em si. É que, sem o harness, ele depende da sorte. Com o harness, ele depende do processo.

5. Orquestração: quando um agente só não basta

Uma tarefa grande, como “criar o módulo de extratos com API, persistência, testes e documentação”, tem partes independentes. Fazer tudo em uma única conversa longa traz dois problemas: o agente esquece instruções do começo conforme o contexto enche, e o trabalho é sequencial, mesmo quando poderia ser paralelo.

Pense em uma entrega de software dividida em frentes: uma pessoa coordena o escopo, especialistas assumem tarefas independentes e uma etapa de integração verifica se o resultado combinado funciona. A orquestração faz algo semelhante, mas precisa lidar com contextos isolados, dependências entre tarefas e custos de execução.

Os padrões mais comuns

    subgraph P1 [1. Encadeamento]
      direction LR
      a1[Especificar] --> a2[Implementar] --> a3[Testar] --> a4[Documentar]
    end
    subgraph P2[2. Orquestrador e workers]
      direction TB
      o[Orquestrador] --> w1[Worker: API]
      o --> w2[Worker: Persistência]
      o --> w3[Worker: Testes]
    end
    subgraph P3[3. Gerador e avaliador]
      direction LR
      g[Gerador] --> av[Avaliador]
      av -- reprovado + motivo --> g
      av -- aprovado --> fim[Entrega]
    end
PadrãoQuando usarCuidado
EncadeamentoEtapas dependentes e bem definidasUm erro no início contamina o resto
Orquestrador e workersSubtarefas independentes que dá para paralelizarWorkers editando os mesmos arquivos geram conflito
Gerador e avaliadorQuando existe um critério claro de qualidadeDefina um limite de rodadas, ou o laço nunca termina

Na prática, os três se combinam. O orquestrador divide, os workers executam em paralelo e um avaliador decide se a entrega passa ou volta com feedback.

O cuidado com o paralelismo: uma pasta por agente

Dois agentes editando a mesma pasta ao mesmo tempo é receita para conflito. O Git oferece uma solução elegante: o git worktree, que cria uma cópia de trabalho independente, em outra pasta, ligada ao mesmo repositório.

# Execute a partir do checkout principal; cada worker recebe uma pasta e branch.
git worktree add ../contas-api -b agente/api
git worktree add ../contas-persistencia -b agente/persistencia

# Revise e integre uma branch por vez, validando o estado combinado.
# Troque `main` pelo nome da branch principal do seu repositório, se necessário.
git switch main
git merge --no-ff agente/api
./mvnw verify
git merge --no-ff agente/persistencia
./mvnw verify

# Só remova os worktrees depois de integrar e guardar o que precisa ser preservado.
git worktree remove ../contas-api
git worktree remove ../contas-persistencia

6. Construindo um mini-harness em Java 25 + Spring Boot 4

Usar ferramentas prontas costuma ser o caminho mais simples. Construir uma versão pequena, por outro lado, ajuda a entender o loop, as ferramentas e os guardrails. O código desta seção é um protótipo didático: não implementa a integração completa com um provedor de IA, gestão de worktrees, merge de resultados, autenticação, limites globais de custo nem isolamento de sistema operacional. Não o exponha como serviço nem execute contra repositórios ou credenciais sensíveis sem completar essas partes.

Uma decisão de projeto: o provedor de IA será representado por uma interface. Assim, o loop pode ser testado sem chamar uma API, e um adaptador HTTP ou Spring AI pode ser conectado depois. A interface precisa receber não apenas o histórico, mas também as descrições das ferramentas que o modelo pode chamar.

Estrutura do projeto, com os principais componentes do harness destacados:

src/main/java/com/exemplo/harness/
├── nucleo/
│   ├── Mensagem.java
│   ├── Passo.java
│   ├── Modelo.java
│   ├── DefinicaoFerramenta.java
│   ├── Ferramenta.java
│   ├── Guardrail.java
│   ├── ResultadoAgente.java
│   └── LoopDoAgente.java
├── ferramentas/
│   ├── Workspace.java
│   ├── LerArquivo.java
│   ├── EscreverArquivo.java
│   └── ExecutarVerificacao.java
├── guardrails/
│   └── CaminhosProibidos.java
├── orquestracao/
│   ├── Planejador.java
│   ├── Avaliador.java
│   ├── Veredito.java
│   ├── Relatorio.java
│   ├── FabricaDeWorkers.java
│   └── Orquestrador.java
└── config/
    ├── HarnessProperties.java
    ├── HarnessConfig.java
    └── OrquestracaoController.java

6.1 O núcleo: o vocabulário do agente

Os tipos selados (sealed) do Java modelam com precisão as únicas coisas que o modelo pode decidir: chamar uma ferramenta ou concluir. O compilador garante que tratamos os dois casos.

package com.exemplo.harness.nucleo;
public record Mensagem(Papel papel, String conteudo) {
    public enum Papel { USUARIO, ASSISTENTE, FERRAMENTA }
}
package com.exemplo.harness.nucleo;
import java.util.Map;
public sealed interface Passo {
    record ChamarFerramenta(String ferramenta, Map<String, String> argumentos) implements Passo {}
    record Concluir(String resposta) implements Passo {}
}
package com.exemplo.harness.nucleo;
import java.util.List;
/** O "cérebro". Em produção é um adaptador para um provedor de IA. */
public interface Modelo {
    Passo proximoPasso(List<Mensagem> historico,
                       List<DefinicaoFerramenta> ferramentasDisponiveis);
}
package com.exemplo.harness.nucleo;
public record DefinicaoFerramenta(String nome, String descricao) {}
package com.exemplo.harness.nucleo;
import java.util.Map;
/** As "mãos" do agente. */
public interface Ferramenta {
    String nome();
    String descricao();
    String executar(Map<String, String> argumentos) throws Exception;
}
package com.exemplo.harness.nucleo;
import java.util.Optional;
/** Um guarda que pode vetar uma chamada de ferramenta antes de ela acontecer. */
@FunctionalInterface
public interface Guardrail {
    /** Retorna o motivo do bloqueio, ou vazio se a chamada está liberada. */
    Optional<String> verificar(Passo.ChamarFerramenta chamada);
}
package com.exemplo.harness.nucleo;
public sealed interface ResultadoAgente {
    record Sucesso(String resposta, int passos) implements ResultadoAgente {}
    record Falha(String motivo, int passos) implements ResultadoAgente {}
}

6.2 O loop: o coração do harness

Este é o ciclo do diagrama da seção 1, em código. Há três proteções: limite de passos, guardrails antes da execução e limite da saída para evitar que uma ferramenta devolva um log enorme ao contexto. O histórico também registra a solicitação de ferramenta e identifica o resultado correspondente; sem isso, o modelo poderia receber a saída sem saber qual chamada a produziu.

package com.exemplo.harness.nucleo;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.exemplo.harness.nucleo.Mensagem.Papel.ASSISTENTE;
import static com.exemplo.harness.nucleo.Mensagem.Papel.FERRAMENTA;
import static com.exemplo.harness.nucleo.Mensagem.Papel.USUARIO;
public final class LoopDoAgente {
    private static final int LIMITE_SAIDA = 4_000;
    private final Modelo modelo;
    private final Map<String, Ferramenta> ferramentas;
    private final List<Guardrail> guardrails;
    private final List<DefinicaoFerramenta> definicoes;
    private final int maxPassos;
    public LoopDoAgente(Modelo modelo, List<Ferramenta> ferramentas,
                        List<Guardrail> guardrails, int maxPassos) {
        if (maxPassos < 1) {
            throw new IllegalArgumentException("maxPassos deve ser maior que zero");
        }
        this.modelo = modelo;
        this.ferramentas = ferramentas.stream()
                .collect(Collectors.toUnmodifiableMap(Ferramenta::nome, Function.identity()));
        this.guardrails = List.copyOf(guardrails);
        this.definicoes = this.ferramentas.values().stream()
                .map(f -> new DefinicaoFerramenta(f.nome(), f.descricao()))
                .toList();
        this.maxPassos = maxPassos;
    }
    public ResultadoAgente executar(String tarefa) {
        var historico = new ArrayList<Mensagem>();
        historico.add(new Mensagem(USUARIO, tarefa));
        for (int passo = 1; passo <= maxPassos; passo++) {
            Passo proximo = modelo.proximoPasso(List.copyOf(historico), definicoes);
            switch (proximo) {
                case Passo.Concluir c -> {
                    historico.add(new Mensagem(ASSISTENTE, c.resposta()));
                    return new ResultadoAgente.Sucesso(c.resposta(), passo);
                }
                case Passo.ChamarFerramenta chamada -> {
                    historico.add(new Mensagem(ASSISTENTE,
                            "Chamada de ferramenta: " + chamada.ferramenta()
                                    + "; argumentos=" + chamada.argumentos()));
                    String resultado = processar(chamada);
                    historico.add(new Mensagem(FERRAMENTA,
                            "Resultado de " + chamada.ferramenta() + ": " + resultado));
                }
            }
        }
        return new ResultadoAgente.Falha(
                "Limite de " + maxPassos + " passos atingido", maxPassos);
    }
    private String processar(Passo.ChamarFerramenta chamada) {
        for (Guardrail guardrail : guardrails) {
            var motivo = guardrail.verificar(chamada);
            if (motivo.isPresent()) {
                return "BLOQUEADO: " + motivo.get();
            }
        }
        var ferramenta = ferramentas.get(chamada.ferramenta());
        if (ferramenta == null) {
            return "ERRO: ferramenta desconhecida. Disponíveis: " + ferramentas.keySet();
        }
        try {
            return truncar(ferramenta.executar(chamada.argumentos()));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "ERRO: execução interrompida";
        } catch (Exception e) {
            return "ERRO: " + e.getMessage();
        }
    }
    private static String truncar(String texto) {
        if (texto == null) {
            return "ERRO: a ferramenta retornou null";
        }
        return texto.length() <= LIMITE_SAIDA
                ? texto
                : texto.substring(0, LIMITE_SAIDA) + "\n[... saída truncada ...]";
    }
}

Dois detalhes de design. Primeiro, erros operacionais retornam como mensagens para que o modelo possa avaliar se existe uma correção possível. Segundo, um guardrail informa o bloqueio e o motivo, em vez de simplesmente encerrar o processo. Em um adaptador real, a chamada de ferramenta precisa ser enviada ao provedor no formato estruturado exigido por sua API, com nomes e argumentos validados, texto concatenado é apenas uma simplificação didática.

6.3 As ferramentas: leitura, escrita e limites de caminho

Nunca entregue ao agente acesso irrestrito ao disco. A classe Workspace impede a travessia mais simples com .. ao normalizar caminhos. Isso não equivale a um sandbox de segurança: links simbólicos, condições de corrida e comandos externos exigem controles adicionais. Use contêiner/VM, usuário sem privilégios, permissões restritas e nenhum segredo acessível ao processo.

package com.exemplo.harness.ferramentas;
import java.nio.file.Path;
public final class Workspace {
    private final Path raiz;
    public Workspace(Path raiz) {
        this.raiz = raiz.toAbsolutePath().normalize();
    }
    public Path raiz() {
        return raiz;
    }
    public Path resolver(String relativo) {
        if (relativo == null || relativo.isBlank()) {
            throw new IllegalArgumentException("Informe um caminho relativo ao workspace");
        }
        var alvo = raiz.resolve(relativo).normalize();
        if (!alvo.startsWith(raiz)) {
            throw new SecurityException("Caminho fora do workspace: " + relativo);
        }
        return alvo;
    }
}
package com.exemplo.harness.ferramentas;
import com.exemplo.harness.nucleo.Ferramenta;
import java.nio.file.Files;
import java.util.Map;
public final class LerArquivo implements Ferramenta {
    private final Workspace workspace;
    public LerArquivo(Workspace workspace) {
        this.workspace = workspace;
    }
    @Override public String nome() { return "ler"; }
    @Override public String descricao() { return "Lê um arquivo. Argumento: caminho (relativo ao projeto)."; }
    @Override
    public String executar(Map<String, String> args) throws Exception {
        return Files.readString(workspace.resolver(args.get("caminho")));
    }
}
package com.exemplo.harness.ferramentas;
import com.exemplo.harness.nucleo.Ferramenta;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
public final class EscreverArquivo implements Ferramenta {
    private final Workspace workspace;
    public EscreverArquivo(Workspace workspace) {
        this.workspace = workspace;
    }
    @Override public String nome() { return "escrever"; }
    @Override public String descricao() {
        return "Cria ou sobrescreve um arquivo. Argumentos: caminho, conteudo.";
    }
    @Override
    public String executar(Map<String, String> args) throws Exception {
        Path destino = workspace.resolver(args.get("caminho"));
        Files.createDirectories(destino.getParent());
        Files.writeString(destino, args.get("conteudo"));
        return "OK: " + workspace.raiz().relativize(destino) + " escrito";
    }
}

A ferramenta de verificação merece atenção especial. Ela não aceita um comando arbitrário do modelo, o processo inicia um executável e argumentos fixos. Isso reduz o risco de injeção de comandos por argumento, mas não transforma a execução em segura por si só: o próprio harness/verify.sh vem do repositório e pode ser alterado se os controles falharem. O código limita o texto lido de volta à memória, mas o arquivo temporário ainda pode crescer até o processo terminar. Execute builds em ambiente descartável, sem credenciais, com limites de CPU, memória, disco, rede e saída. Ao estourar o timeout, destroyForcibly() mira o processo iniciado; processos descendentes podem exigir gerenciamento explícito. Limite de tempo sozinho não é sandbox.

package com.exemplo.harness.ferramentas;
import com.exemplo.harness.nucleo.Ferramenta;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public final class ExecutarVerificacao implements Ferramenta {
    private static final int LIMITE_LOG_BYTES = 1_000_000;
    private final Workspace workspace;
    private final Duration timeout;
    public ExecutarVerificacao(Workspace workspace, Duration timeout) {
        this.workspace = workspace;
        this.timeout = timeout;
    }
    @Override public String nome() { return "verificar"; }
    @Override public String descricao() {
        return "Roda a verificação completa (build, testes, arquitetura). Sem argumentos.";
    }
    @Override
    public String executar(Map<String, String> args) throws Exception {
        // A saída vai para um arquivo temporário para o timeout funcionar
        // mesmo se o processo produzir muita saída.
        var log = Files.createTempFile("verify-", ".log");
        try {
            var processo = new ProcessBuilder("./harness/verify.sh", "full")
                    .directory(workspace.raiz().toFile())
                    .redirectErrorStream(true)
                    .redirectOutput(log.toFile())
                    .start();
            if (!processo.waitFor(timeout.toSeconds(), TimeUnit.SECONDS)) {
                processo.destroyForcibly();
                return "exit=TIMEOUT (" + timeout.toSeconds() + "s)";
            }
            byte[] bytes;
            try (var entrada = Files.newInputStream(log)) {
                bytes = entrada.readNBytes(LIMITE_LOG_BYTES);
            }
            var saida = new String(bytes, StandardCharsets.UTF_8);
            if (Files.size(log) > LIMITE_LOG_BYTES) {
                saida += "\n[saída do processo truncada]";
            }
            return "exit=" + processo.exitValue() + "\n" + saida;
        } finally {
            Files.deleteIfExists(log);
        }
    }
}

6.4 O guardrail

package com.exemplo.harness.guardrails;
import com.exemplo.harness.nucleo.Guardrail;
import com.exemplo.harness.nucleo.Passo;
import java.util.List;
import java.util.Optional;
/** Filtro didático complementar; não é uma fronteira de segurança. */
public record CaminhosProibidos(List<String> trechos) implements Guardrail {
    @Override
    public Optional<String> verificar(Passo.ChamarFerramenta chamada) {
        var caminho = chamada.argumentos().getOrDefault("caminho", "");
        return trechos.stream()
                .filter(caminho::contains)
                .findFirst()
                .map(trecho -> "'" + trecho + "' é protegido. Peça autorização humana.");
    }
}

6.5 O orquestrador: paralelismo com Virtual Threads

Agora a peça principal. Cada worker pode passar boa parte do tempo esperando a resposta do modelo ou o resultado do build, cenário em que Virtual Threads podem ser úteis. Elas reduzem o custo de manter muitas tarefas bloqueantes, não aceleram trabalho de CPU, não tornam chamadas externas gratuitas e não dispensam limites de concorrência. O número de workers deve ser limitado para não saturar a API do modelo, o disco ou o build.

Primeiro, as interfaces e os tipos do domínio da orquestração:

package com.exemplo.harness.orquestracao;
import java.util.List;
/** Quebra um objetivo em subtarefas independentes. Em produção, costuma ser uma chamada ao modelo. */
public interface Planejador {
    List<String> dividir(String objetivo);
}
package com.exemplo.harness.orquestracao;
import com.exemplo.harness.nucleo.ResultadoAgente;
import java.util.List;
public interface Avaliador {
    Veredito avaliar(String objetivo, List<ResultadoAgente> resultados);
}
package com.exemplo.harness.orquestracao;
public sealed interface Veredito {
    record Aprovado() implements Veredito {}
    record Reprovado(String motivo) implements Veredito {}
}
package com.exemplo.harness.orquestracao;
import com.exemplo.harness.nucleo.ResultadoAgente;
import java.util.List;
public record Relatorio(boolean aprovado, int rodadas,
                        List<ResultadoAgente> resultados, String observacao) {}

A fábrica separa a criação do worker da lógica do orquestrador:

package com.exemplo.harness.orquestracao;

import com.exemplo.harness.nucleo.LoopDoAgente;

@FunctionalInterface
public interface FabricaDeWorkers {
    // Deve devolver um worker ligado ao worktree exclusivo deste identificador.
    LoopDoAgente criar(String workerId);
}

E o orquestrador em si:

package com.exemplo.harness.orquestracao;

import com.exemplo.harness.nucleo.LoopDoAgente;
import com.exemplo.harness.nucleo.ResultadoAgente;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public final class Orquestrador {

    private static final int MAX_SUBTAREFAS = 8;

    private final Planejador planejador;
    private final FabricaDeWorkers fabricaDeWorkers;
    private final Avaliador avaliador;
    private final int maxRodadas;

    public Orquestrador(Planejador planejador, FabricaDeWorkers fabricaDeWorkers,
                        Avaliador avaliador, int maxRodadas) {
        if (maxRodadas < 1) {
            throw new IllegalArgumentException("maxRodadas deve ser maior que zero");
        }
        this.planejador = planejador;
        this.fabricaDeWorkers = fabricaDeWorkers;
        this.avaliador = avaliador;
        this.maxRodadas = maxRodadas;
    }

    public Relatorio executar(String objetivo) throws InterruptedException {
        var subtarefas = planejador.dividir(objetivo);
        if (subtarefas.isEmpty() || subtarefas.size() > MAX_SUBTAREFAS) {
            throw new IllegalArgumentException(
                    "O plano deve conter de 1 a " + MAX_SUBTAREFAS + " subtarefas");
        }
        var feedback = "";
        List<ResultadoAgente> ultimos = List.of();

        for (int rodada = 1; rodada <= maxRodadas; rodada++) {
            ultimos = executarEmParalelo(subtarefas, feedback);
            switch (avaliador.avaliar(objetivo, ultimos)) {
                case Veredito.Aprovado a ->
                        return new Relatorio(true, rodada, ultimos, "Aprovado pelo avaliador");
                case Veredito.Reprovado r -> feedback = r.motivo();
            }
        }

        return new Relatorio(false, maxRodadas, ultimos,
                "Limite de rodadas atingido sem aprovação. Revisão humana necessária.");
    }

    private List<ResultadoAgente> executarEmParalelo(List<String> subtarefas, String feedback)
            throws InterruptedException {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var futuros = new ArrayList<java.util.concurrent.Future<ResultadoAgente>>();
            for (int i = 0; i < subtarefas.size(); i++) {
                final int indice = i;
                final String tarefa = montarPrompt(subtarefas.get(i), feedback);
                futuros.add(executor.submit(() ->
                        fabricaDeWorkers.criar("worker-" + indice).executar(tarefa)));
            }

            var resultados = new ArrayList<ResultadoAgente>();
            for (var futuro : futuros) {
                try {
                    resultados.add(futuro.get(10, TimeUnit.MINUTES));
                } catch (TimeoutException e) {
                    futuro.cancel(true);
                    resultados.add(new ResultadoAgente.Falha(
                            "Worker excedeu o limite de 10 minutos", 0));
                } catch (ExecutionException e) {
                    resultados.add(new ResultadoAgente.Falha(
                            "Worker falhou: " + e.getCause().getMessage(), 0));
                }
            }
            return resultados;
        }
    }

    private static String montarPrompt(String subtarefa, String feedback) {
        if (feedback.isBlank()) {
            return subtarefa;
        }
        return subtarefa + "\n\nO avaliador reprovou a tentativa anterior com este motivo:\n"
                + feedback + "\nCorrija e tente novamente.";
    }
}

O timeout aplicado a Future.get é uma proteção parcial, não um limite absoluto de duração. cancel(true) solicita a interrupção da thread, mas uma chamada HTTP ou um processo externo pode continuar se não respeitar essa interrupção, além disso, o fechamento do executor espera as tarefas terminarem. Em uma implementação real, configure deadlines no cliente HTTP, gerencie processos filhos e estabeleça limites de concorrência e de tempo no nível do provedor e do sistema operacional.

A avaliação final precisa verificar o resultado integrado, não apenas o resultado isolado de cada worker. O esqueleto acima não inclui a etapa de criação de um checkout de integração nem o merge automático: antes de conectar um avaliador real, implemente esse ciclo com branches/worktrees isolados e valide os conflitos. Sem isso, vários workers podem produzir código que passa isoladamente, mas não compila quando combinado.

Um juiz determinístico, aqui apoiado no mesmo verify.sh, para não depender de “opinião” quando existe um fato:

package com.exemplo.harness.orquestracao;
import com.exemplo.harness.nucleo.Ferramenta;
import com.exemplo.harness.nucleo.ResultadoAgente;
import java.util.List;
import java.util.Map;
public final class AvaliadorPorVerificacao implements Avaliador {
    private final Ferramenta verificar;
    public AvaliadorPorVerificacao(Ferramenta verificar) {
        this.verificar = verificar;
    }
    @Override
    public Veredito avaliar(String objetivo, List<ResultadoAgente> resultados) {
        for (var resultado : resultados) {
            if (resultado instanceof ResultadoAgente.Falha falha) {
                return new Veredito.Reprovado("Uma subtarefa falhou: " + falha.motivo());
            }
        }
        String saida;
        try {
            saida = verificar.executar(Map.of());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return new Veredito.Reprovado("Verificação interrompida");
        } catch (Exception e) {
            return new Veredito.Reprovado("Verificação não executou: " + e.getMessage());
        }
        return saida.startsWith("exit=0")
                ? new Veredito.Aprovado()
                : new Veredito.Reprovado(saida);
    }
}

6.6 Ligando tudo no Spring Boot 4

As configurações viram um record tipado, e o Spring faz a conversão de Path e Duration a partir do application.yml:

package com.exemplo.harness.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import java.nio.file.Path;
import java.time.Duration;
@ConfigurationProperties(prefix = "harness")
public record HarnessProperties(
        Path workspace,
        @DefaultValue("25") int maxPassos,
        @DefaultValue("3") int maxRodadas,
        @DefaultValue("10m") Duration timeoutVerificacao) {}
# application.yml
harness:
  workspace: /srv/projetos/contas
  max-passos: 25
  max-rodadas: 3
  timeout-verificacao: 10m
package com.exemplo.harness.config;
import com.exemplo.harness.ferramentas.ExecutarVerificacao;
import com.exemplo.harness.ferramentas.Workspace;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(HarnessProperties.class)
class HarnessConfig {
    @Bean
    Workspace workspace(HarnessProperties props) {
        // Adequado para um worker sequencial ou para um workspace isolado.
        return new Workspace(props.workspace());
    }
    @Bean
    ExecutarVerificacao executarVerificacao(Workspace workspace, HarnessProperties props) {
        return new ExecutarVerificacao(workspace, props.timeoutVerificacao());
    }
}

Os beans Modelo e Planejador ficam por sua conta. Também falta implementar a FabricaDeWorkers para provisionar e reutilizar um worktree por worker, além de integrar as branches em um checkout temporário antes da avaliação final. Por esse motivo, o exemplo de configuração não registra automaticamente o orquestrador paralelo: conectá-lo a um Workspace singleton compartilhado permitiria que workers sobrescrevessem os arquivos uns dos outros.

package com.exemplo.harness.config;
import com.exemplo.harness.orquestracao.Orquestrador;
import com.exemplo.harness.orquestracao.Relatorio;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@ConditionalOnBean(Orquestrador.class)
@RequestMapping("/orquestracoes")
class OrquestracaoController {
    record Pedido(String objetivo) {}
    private final Orquestrador orquestrador;
    OrquestracaoController(Orquestrador orquestrador) {
        this.orquestrador = orquestrador;
    }
    @PostMapping
    Relatorio criar(@RequestBody Pedido pedido) throws InterruptedException {
        return orquestrador.executar(pedido.objetivo());
    }
}

Uma ressalva honesta: esse endpoint é síncrono, e uma orquestração real pode levar minutos. Em produção, o ideal é devolver um identificador imediatamente (202 Accepted) e permitir consulta posterior do resultado.

6.7 Testando o harness sem gastar um centavo

Como o modelo é uma interface, podemos usar um “ator” com roteiro fixo. Os testes ficam rápidos, determinísticos e gratuitos:

package com.exemplo.harness;
import com.exemplo.harness.ferramentas.EscreverArquivo;
import com.exemplo.harness.ferramentas.Workspace;
import com.exemplo.harness.guardrails.CaminhosProibidos;
import com.exemplo.harness.nucleo.*;
import com.exemplo.harness.orquestracao.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
class HarnessTest {
    /** Modelo de mentira: devolve passos pré-definidos e guarda o último histórico recebido. */
    static final class ModeloRoteirizado implements Modelo {
        private final Iterator<Passo> roteiro;
        List<Mensagem> ultimoHistorico = List.of();
        ModeloRoteirizado(Passo... passos) {
            this.roteiro = List.of(passos).iterator();
        }
        @Override
        public Passo proximoPasso(List<Mensagem> historico,
                                  List<DefinicaoFerramenta> ferramentasDisponiveis) {
            this.ultimoHistorico = historico;
            return roteiro.next();
        }
    }
    @Test
    void guardrailImpedeEscritaEmArquivoProtegido(@TempDir Path pasta) {
        var workspace = new Workspace(pasta);
        var modelo = new ModeloRoteirizado(
                new Passo.ChamarFerramenta("escrever",
                        Map.of("caminho", ".env", "conteudo", "SEGREDO=1")),
                new Passo.Concluir("terminei"));
        var loop = new LoopDoAgente(modelo,
                List.of(new EscreverArquivo(workspace)),
                List.of(new CaminhosProibidos(List.of(".env"))),
                5);
        var resultado = loop.executar("grave o segredo");
        assertThat(resultado).isInstanceOf(ResultadoAgente.Sucesso.class);
        assertThat(Files.exists(pasta.resolve(".env"))).isFalse();
        assertThat(modelo.ultimoHistorico.getLast().conteudo()).contains("BLOQUEADO");
    }
    @Test
    void workspaceRejeitaCaminhoComTravessia(@TempDir Path pasta) {
        var workspace = new Workspace(pasta);
        var modelo = new ModeloRoteirizado(
                new Passo.ChamarFerramenta("escrever",
                        Map.of("caminho", "../fora.txt", "conteudo", "x")),
                new Passo.Concluir("ok"));
        new LoopDoAgente(modelo, List.of(new EscreverArquivo(workspace)), List.of(), 5)
                .executar("tente escapar");
        assertThat(modelo.ultimoHistorico.getLast().conteudo())
                .contains("ERRO")
                .contains("fora do workspace");
    }
    @Test
    void loopParaNoLimiteDePassos() {
        // Modelo que nunca conclui: sempre pede uma ferramenta inexistente.
        Modelo teimoso = (historico, ferramentas) -> new Passo.ChamarFerramenta("nada", Map.of());
        var resultado = new LoopDoAgente(teimoso, List.of(), List.of(), 3).executar("tarefa");
        assertThat(resultado).isEqualTo(new ResultadoAgente.Falha("Limite de 3 passos atingido", 3));
    }
    @Test
    void orquestradorTentaDeNovoComFeedbackAteAprovar() throws Exception {
        Planejador planejador = objetivo -> List.of("parte A", "parte B");
        var avaliacoes = new AtomicInteger();
        Avaliador avaliador = (objetivo, resultados) ->
                avaliacoes.incrementAndGet() == 1
                        ? new Veredito.Reprovado("faltou teste")
                        : new Veredito.Aprovado();
        var orquestrador = new Orquestrador(
                planejador,
                workerId -> new LoopDoAgente(
                        new ModeloRoteirizado(new Passo.Concluir("feito")),
                        List.of(), List.of(), 5),
                avaliador,
                3);
        var relatorio = orquestrador.executar("criar módulo de extratos");
        assertThat(relatorio.aprovado()).isTrue();
        assertThat(relatorio.rodadas()).isEqualTo(2);
        assertThat(relatorio.resultados()).hasSize(2);
    }
}

O teste do orquestrador, em particular, valida o comportamento mais importante do sistema: reprovou, voltou com o motivo, tentou de novo, passou na segunda rodada.

7. Melhores práticas

Reunindo o que vale para qualquer ferramenta ou linguagem:

  1. Comece pelo AGENTS.md, e mantenha-o curto. Comandos exatos e regras inegociáveis. Texto longo demais é ignorado.

  2. Transforme regra em teste. O que está só em texto é sugestão. O que quebra o build é lei.

  3. Escreva mensagens de erro para o agente. O texto de uma falha é a instrução de correção mais valiosa que ele recebe.

  4. Sensores rápidos primeiro. Formatação e arquitetura em segundos; suíte completa só no final.

  5. Quem revisa não edita. Separe gerador e avaliador, e dê ao avaliador menos ferramentas.

  6. Prefira juízes determinísticos. Use “IA como juiz” para o que é realmente subjetivo, não para o que um teste resolve.

  7. Poder mínimo. Ferramentas com comandos fixos, sandbox de arquivos, permissões estreitas. O que o agente não pode fazer, não pode dar errado.

  8. Todo laço tem um freio. Limite de passos, limite de rodadas, timeout. E, ao estourar, quem decide é um humano.

  9. Isole o trabalho paralelo. Um git worktree por agente, ou subtarefas que não toquem os mesmos arquivos.

  10. Trate o harness como código. Versione, revise e teste. Com o tempo, guias e sensores podem passar a se contradizer, faça uma auditoria periódica.

  11. Use erros recorrentes para melhorar o processo. Se o agente repetir o mesmo desvio, revise as instruções, os exemplos, as ferramentas disponíveis e as verificações. Nem todo erro pode ser eliminado automaticamente, mas muitos podem ser detectados mais cedo e ficar mais fáceis de corrigir.

8. Trade-offs: quando vale a pena (e quando não vale)

Seria desonesto vender isso como solução universal.

O custo existe. Montar guias, sensores e hooks leva tempo, e o harness também precisa de manutenção. Em um script descartável ou em uma exploração rápida, é exagero.

Mais agentes não é melhor. Orquestração multiplica custo (cada agente consome tokens), latência e superfícies de falha. Uma boa regra: comece com um agente bem configurado e só divida quando houver uma razão concreta, como subtarefas realmente independentes ou a necessidade de uma revisão isolada.

Sensores não cobrem tudo. Testes de arquitetura e linters verificam propriedades específicas, não a correção integral do sistema. Um agente pode escrever testes que passam e ainda validar a regra errada. Critérios de aceite ambíguos e decisões de negócio continuam exigindo definição e revisão humanas.

Paralelismo tem custo de integração. Dividir o trabalho é barato, juntar os resultados sem conflitos semânticos nem sempre é.

Vale a pena quando: o projeto é longo e tem convenções claras, várias pessoas (e agentes) mexem no mesmo código, o custo de um erro é alto, ou você percebe que está corrigindo os mesmos deslizes repetidamente.

9. Fechamento

Uma pergunta natural é “qual modelo é o melhor?”. Ela importa, mas não é a única variável. A qualidade do contexto, o desenho das ferramentas, os limites de permissão e a capacidade de verificar o resultado também influenciam o desempenho prático de um agente.

Há algo curioso nisso: quase tudo que compõe um bom harness é boa engenharia de software de sempre. Documentação clara, testes que protegem a arquitetura, automação confiável, revisão por pares, princípio do menor privilégio. A IA não tornou essas práticas obsoletas, tornou-as a diferença entre um agente que ajuda e um que cria retrabalho em alta velocidade.

O papel do engenheiro não desaparece: continuamos responsáveis por definir o que é correto, estabelecer critérios verificáveis e avaliar os riscos que a automação não consegue resolver sozinha. A diferença é que parte desse conhecimento precisa estar explícita o suficiente para orientar uma máquina e permitir que ferramentas independentes verifiquem o resultado.


Referências