Pular para o conteúdo
23/30Capítulo 23 de 30

Construa um agent harness: o loop e suas cinco saídas

Um loop de quinze linhas que funciona de primeira — depois é quebrado de propósito, incluindo um runaway 77 vezes mais caro.

Nesta página

Comece pela parte honesta, porque ninguém mais vai dizer: “harness” é jargão, não um padrão. Não há especificação, comitê nem definição de referência. Os quatro artigos citados neste capítulo — ReAct,1 CoALA,2 SWE-bench e vLLM — não usam a palavra nenhuma vez em seus resumos. A implementação mais baixada da coisa, o pacote ai da Vercel, com 89,4 milhões de downloads por mês, também não a usa: a string harness aparece zero vezes nos 397 KB de declarações de tipo enviados pela versão 7.0.93.3 O único lugar em que a palavra realmente sustenta significado quer dizer outra coisa. O SWE-bench diz “harness” cinco vezes em seu README, sempre como evaluation harness — o andaime conteinerizado que aplica um patch e roda os testes — e seu módulo Python é literalmente swebench.harness.run_evaluation.4

Então duas coisas diferentes compartilham um nome. Um evaluation harness mantém o agent parado e o pontua. Um agent harness é o programa que executa o agent: chama o modelo, executa o que o modelo pede, decide quando parar e mantém o estado no meio do caminho. Este capítulo constrói o segundo, em menos de duzentas linhas de TypeScript, sem framework algum.

O loop em si tem quinze linhas e funciona na primeira tentativa. Tudo depois disso é uma forma de sair dele.

Mostrar detalhes

O que este capítulo precisa dos anteriores.

  • Capítulo 14 para o cliente: prazos, triagem de status, cancelamento, chaves de idempotência e a técnica de provedor simulado usada aqui de novo.
  • Capítulo 16 para a aritmética: input tokens crescem com o quadrado da conversa, e as tarifas usadas abaixo são as lidas lá em 6 de setembro de 2026.
  • Capítulo 18 para o catálogo de ferramentas: um schema que o modelo vê, um endpoint que ele nunca vê e a regra de que erros são contexto, não exceções.
  • Capítulo 22 para o loop que este herda e para as duas definições publicadas de “agent” que discordam entre si.

Sem tensores aqui. Este é o segundo hub de dependências do curso: os capítulos 24, 25, 29 e 30 rodam sobre o arquivo abaixo, e os capítulos 26 a 28 constroem em cima do que ele consegue alcançar.

O capítulo 14 não podia ser escrito contra um provedor real, porque você não consegue pedir a ele um 429 em um momento escolhido. Este capítulo tem o mesmo problema em outro formato: você não consegue pedir a um modelo real que entre em runaway, ou que solicite a mesma ferramenta duas vezes seguidas, sob demanda e de forma reproduzível.

Então o primeiro programa é um provedor roteirizado: um endpoint com o formato de uma API de chat completions cuja resposta é função do índice do turno e do que as ferramentas devolveram até agora. Ele conta tokens com um encoder byte-pair real, então o dinheiro abaixo é aritmética, não enfeite.

mock-provider.mjsJS
const SCRIPTS = {
  // A well-behaved task: list, read, answer.
  plan: (t) =>
    t === 0 ? asks(call("c1", "list_files", {}))
    : t === 1 ? asks(call("c2", "read_file", { path: "errors.log" }))
    : text("errors.log mentions a timeout: worker 7 timed out after 30000 ms."),

  // Never declares itself done.
  runaway: (t) => asks(call(`c${t}`, "list_files", {})),           

  // Guesses a file name, then corrects itself IF it was told what happened.
  recover: (t, all) =>
    t === 0 ? asks(call("c1", "read_file", { path: "timeout.log" }))
    : /Call list_files/.test(all)                                  
      ? (t === 1 ? asks(call("c2", "list_files", {}))
        : t === 2 ? asks(call("c3", "read_file", { path: "errors.log" }))
        : text("errors.log mentions a timeout."))
      : text("I could not read the file, so I do not know."),
};

const turn = messages.filter((m) => m.role === "assistant").length;             
const toolText = messages.filter((m) => m.role === "tool").map((m) => m.content).join("\n");
const message = SCRIPTS[scenario](turn, toolText);

Duas linhas carregam o design. O índice do turno é derivado da conversa, não mantido em uma variável, então o provedor é sem estado e uma execução pode ser morta e retomada contra ele. E recover lê os resultados das ferramentas antes de decidir: um modelo roteirizado que lê sua própria transcrição é o mínimo necessário para medir se o harness deu a ele algo que valha a pena ler.

O catálogo é o do capítulo 18, quatro ferramentas em três arquivos: list_files, read_file, delete_file — marcada como needsApproval — e scan_archive, que é lenta de propósito.

Aqui está a ideia inteira, antes de qualquer uma das partes que a tornam sobrevivível.

loop.tsTS
while (true) {
  const reply = await callModel(base, messages, tools, signal);
  messages.push(reply.message);

  const calls = reply.message.tool_calls ?? [];
  if (!calls.length) return reply.message.content;          

  for (const c of calls) {
    const tool = byName.get(c.function.name);
    const result = await tool.run(JSON.parse(c.function.arguments));
    messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: result });
  }
}

Aponte-o para o provedor roteirizado e ele faz exatamente o que parece fazer:

TEXT
plan, cap 20    turns=3  tools=2  in=815  out=70  cost=$0.002470  ms=89  status=completed
   answer: "errors.log mentions a timeout: worker 7 timed out after 30000 ms."
   per-turn prompt tokens: 204, 269, 342

Três turnos, duas execuções de ferramenta, um quarto de centavo de dólar. Repare na última linha: 204, 269, 342. Cada turno reenvia tudo que veio antes, que é a conta quadrática do capítulo 16 chegando a um lugar onde ninguém digitou nada. O resto deste capítulo é o que acontece quando essa linha não para de crescer.

Aponte o mesmo loop para o script runaway — um modelo que pede uma ferramenta em todo turno e nunca emite prosa — e o return marcado nunca dispara. Não há outra saída. O programa roda até o processo morrer ou o cartão de crédito morrer.

A correção é uma linha, é o primeiro controle recomendado pela literatura,5 e todo mundo acaba escrevendo. O que quase ninguém faz é medir quanto ela vale:

limite de turnoschamadas ao modeloinput tokenscusto
883.431$0.009070
202016.259$0.038038
505088.649$0.191098
100100337.299$0.702198

Leia as duas últimas linhas juntas. Dobrar o limite de 50 para 100 não dobrou o custo; multiplicou por 3,7. Os input tokens passaram de 88.649 para 337.299, um fator de 3,8, porque o turno nn carrega todos os turnos anteriores consigo e o total é Θ(n2)\Theta(n^2). Um limite de turnos não é um dial linear. É um dial na raiz quadrada do seu pior caso, por isso aumentar de 20 para 100 “só por segurança” é uma decisão que vale precificar antes de tomar.

Quebra dois: um limite de turnos não é um limite de dinheiro

Link para a seção: Quebra dois: um limite de turnos não é um limite de dinheiro

O problema com um limite de turnos é que um turno não tem preço fixo. Vinte turnos sobre uma transcrição curta custaram $0.038 acima. Vinte turnos com um catálogo de 200 ferramentas, um conjunto de documentos recuperados e quarenta mensagens de histórico custam centenas de vezes isso, e o limite não sabe. O que o operador quer limitar é a conta.

Então o loop conta dinheiro, usando o computeCost do capítulo 16 contra as tarifas lidas lá — $2.00 por milhão de input tokens e $12.00 por milhão de output, para o modelo precificado ao longo deste curso:

harness.tsTS
const PRICE_IN = 2.0 / 1e6, PRICE_OUT = 12.0 / 1e6;
export const cost = (u: Usage) => u.prompt_tokens * PRICE_IN + u.completion_tokens * PRICE_OUT;

// at the top of every iteration, before asking the model anything:
if (state.turns >= opts.limits.maxTurns) return stop("max_turns_exceeded", { type: "max_turns" });
if (state.costUsd >= opts.limits.maxBudgetUsd) return stop("budget_exceeded", { type: "max_budget" }); 

// ...and once the reply is back, before anything else happens with it:
state.costUsd += cost(reply.usage);

Mesmo script runaway, sem limite de turnos nenhum, três orçamentos:

orçamentoturnos alcançadosgasto real
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

Duas coisas merecem nome. Primeiro, o orçamento compra um número diferente de turnos a cada vez, que é justamente o ponto: ele limita aquilo que importa para o operador e deixa a contagem de turnos cair onde a transcrição a coloca. Segundo, toda linha estoura o limite. O orçamento era $0.010 e $0.010780 foi gasto, porque a verificação roda antes de um turno e o preço de um turno não é conhecido até ele terminar. Você não consegue limitar gasto exatamente; consegue limitá-lo a até o custo de um turno. Diga isso na interface em vez de fingir, e ponha a verificação antes da chamada para que o estouro seja de um turno, não de dois.

A essa altura o loop tem três saídas, e o formato do resto do capítulo está visível. Uma execução de produção termina exatamente de uma de cinco formas, e elas não são variações umas das outras:

como terminaquem decidiuo que o chamador deve fazer
o modelo parou de pediro modeloleia a resposta
limite de turnosvocê, antesaumente o limite ou aceite um resultado parcial
orçamento esgotadovocê, antesaprove mais dinheiro ou aceite um resultado parcial
um erro que você não pode tentar de novoo provedor ou uma ferramentacorrija o deployment; a triagem do capítulo 14 decide
um humano interveiouma pessoaaguarde um veredito e então retome

Colapsar isso em um boolean é o erro de design mais comum neste arquivo, e ele é caro de um jeito específico: três das cinco são retomáveis e duas não são. Um agent que atingiu seu limite de turnos tem uma transcrição válida, um resultado parcial real e um próximo passo; um agent que recebeu um 401 não tem nada disso. Então o harness registra a razão como dados:

harness.tsTS
export type RunStatus =
  | "running" | "completed" | "failed"
  | "max_turns_exceeded" | "budget_exceeded" | "interrupted";

export type Interruption =
  | { type: "approval"; callId: string; toolName: string; args: unknown }
  | { type: "max_turns" } | { type: "max_budget" }
  | { type: "cancelled"; reason: string };

O capítulo 18 terminou com uma afirmação sem número: devolva o erro de uma ferramenta ao modelo como resultado de ferramenta em vez de lançá-lo, e o modelo geralmente se corrige. Aqui está o número.

Uma falha, três políticas. O modelo roteirizado chuta um arquivo que não existe; a ferramenta lança no such file: timeout.log. Call list_files to see what exists.

o que o harness faz com o erroturnosexecuções de ferramentacustoo que o usuário recebeu
joga para fora do loop11$0.000756um stack trace
retorna Error: the tool failed.21$0.001462“Não consegui ler o arquivo, então não sei.”
retorna o que realmente aconteceu43$0.003550“errors.log menciona um timeout.”

A terceira linha custa 4,7 vezes a primeira e é a única que responde à pergunta. E a segunda linha é a interessante, porque é o que a maioria dos codebases realmente faz: o erro foi capturado, o loop sobreviveu, o modelo foi informado que algo falhou, mas não o quê, e desistiu educadamente. A diferença entre as linhas dois e três não é tratamento de erro. É uma frase escrita para um leitor.

Portanto, o harness trata uma ferramenta que lançou erro como dados e transforma a redação em política:

harness.tsTS
} catch (err: any) {
  if (signal.aborted) return stop("interrupted", { type: "cancelled", reason: String(signal.reason) });
  if (opts.toolErrorsAreFatal) { state.error = err.message; return stop("failed"); }
  result = (opts.toolErrorText ?? ((e: Error) => `Error: ${e.message}`))(err);   
}

O capítulo 18 também alertou sobre o outro lado, que também tem preço. Aponte o loop para uma ferramenta que falha por um motivo que nenhuma mensagem pode corrigir — uma leitura que o processo não tem permissão para realizar — e o modelo tenta de novo para sempre:

TEXT
read a file the process may not open   turns=12  toolruns=11  in=7,079  cost=$0.018622
                                      status=max_turns_exceeded   answer=""

Onze execuções idênticas de uma chamada que não pode dar certo, 5,2 vezes o custo da execução que se recuperou de uma falha corrigível, e nada no fim. Erros são contexto; um erro permanente é contexto que envenena o resto da execução. A distinção é a triagem de status do capítulo 14 movida uma camada para cima: um erro sobre o qual o modelo pode agir volta para a transcrição, e um erro sobre o qual ele não pode deveria parar a execução com uma razão. O limite de turnos é o que fica entre você e o segundo caso hoje, o que é um piso, não uma correção.

Agora a falha que a maioria das pessoas presume que não pode acontecer. Modelos se repetem. Peça a qualquer loop que rode por tempo suficiente e você verá a mesma ferramenta com os mesmos argumentos em dois turnos consecutivos.

Medido contra a baseline da mesma tarefa sem repetição:

turnosexecuções de ferramentacusto
a tarefa, sem repetição21$0.001396
a mesma tarefa, uma chamada repetida32$0.002446
repetida, com cache de resultado em ferramentas somente leitura31$0.002446

A chamada duplicada custou $0.001050 a mais, um aumento de 75%, e aqui está a parte que surpreende as pessoas: fazer cache do resultado não recuperou nada disso. A deduplicação economizou a execução da ferramenta, não o turno, porque no momento em que seu código percebe a repetição o modelo já foi pago por pedir. A economia é real quando a ferramenta é lenta, limitada por rate limit ou cobrada por chamada — e é zero no item da conta que cresceu.

Há uma versão pior. Aplique o mesmo cache a uma ferramenta que escreve, e a segunda chamada simplesmente não acontece:

TEXT
naive cache on every tool        3 turns, 1 tool run,  files deleted: ["access.log"]
cache only on read-only tools    3 turns, 2 tool runs, files deleted: ["access.log","access.log"]

Qual delas está correta? Nenhuma, de forma que se possa saber. O protocolo diz que são duas chamadas: elas carregam dois valores tool_call_id diferentes. Os argumentos dizem que talvez sejam uma só. Um harness que decide comparando strings de argumentos um dia vai engolir a segunda de duas cobranças idênticas e intencionais — e o capítulo 14 já nomeou o único mecanismo que resolve isso honestamente, que é uma chave de idempotência gerada por operação lógica pela camada que sabe o que a operação é. Até a ferramenta carregar uma, o padrão defensável é o gate somente leitura acima: faça cache de leituras, execute escritas e deixe a idempotência da própria escrita cuidar do resto.

harness.tsTS
if (opts.dedupe && (tool.readOnly || opts.dedupeAll) && seen.has(signature)) {   
  state.messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: seen.get(signature)! });
  continue;
}

O script destructive lista os arquivos e então pede para apagar um que a tarefa nunca mencionou. Nada no loop até aqui o impediria.

Uma ferramenta marcada como needsApproval não falha e não prossegue. Ela para a execução e devolve o controle, com tudo que uma pessoa precisa para decidir:

harness.tsTS
if (tool.needsApproval && !state.approved.includes(c.id)) {
  trace(state.runId, "approval_required", { toolName: tool.name, args: c.function.arguments, callId: c.id });
  return stop("interrupted", { type: "approval", callId: c.id, toolName: tool.name, args: JSON.parse(c.function.arguments) });
}
TEXT
stopped at turn 2: interrupted / approval -> delete_file({"path":"access.log"})
files deleted so far: []
approve -> total turns=3  deleted=["access.log"]  "Deleted access.log to free space."
reject  -> total turns=3  deleted=[]              "I did not delete anything: you declined the deletion."

Esse é o mecanismo inteiro, e a razão de ele ser um retorno em vez de um callback é a próxima seção: entre a parada e o veredito, o processo pode nem existir mais.

Mas antes, a medição que ninguém espera. Uma rejeição não é ausência de resultado — a transcrição tem um slot identificado por tool_call_id e algo precisa entrar nele. Rode a mesma rejeição duas vezes, mudando apenas o que esse algo diz:

TEXT
rejected with a reason   deleted=[]  the agent then told the user:
                                     "I did not delete anything: you declined the deletion."
rejected with nothing    deleted=[]  the agent then told the user:
                                     "Deleted access.log to free space."

Nada foi apagado em nenhuma das execuções, e na segunda o usuário é informado de que foi. O sistema de permissões funcionou perfeitamente; o relatório é uma mentira. É o mesmo mecanismo da tabela de erros de ferramenta, chegando a um lugar muito mais importante — um humano disse não, a ação foi corretamente bloqueada, e o resumo do agent contradiz a realidade porque a recusa nunca foi escrita onde o modelo lê. A regra que sai disso é curta: seja qual for a decisão do seu código sobre uma chamada de ferramenta, escreva a decisão na transcrição, em palavras. O capítulo 30 volta a isso pelo lado da segurança, onde é a diferença entre uma trilha de auditoria e ficção.

Uma aprovação leva minutos ou horas. Um deploy leva segundos. Se a execução vive em uma variável local dentro de uma requisição HTTP, todo restart é uma execução perdida e toda aprovação é uma corrida.

Então a execução não é um closure. Ela é um objeto simples e serializável — mensagens, contagem de turnos, custo, status, interrupção, lista de ids de chamadas aprovadas — e o loop é uma função pura sobre ele. Essa única restrição é o que torna persistência uma preocupação de uma linha:

harness.tsTS
export const save = (s: RunState, dir: string) => writeFileSync(`${dir}/${s.runId}.json`, JSON.stringify(s));
export const load = (dir: string, runId: string) => JSON.parse(readFileSync(`${dir}/${runId}.json`, "utf8"));

A pergunta de correção não é salvar. É o que acontece no caminho de volta, e a resposta ingênua cobra de você duas vezes. Se o processo morreu depois que o modelo pediu uma ferramenta, mas antes de o resultado ser escrito, uma retomada que começa chamando o modelo de novo paga por um turno que já tem — e, se começa rodando as ferramentas de novo, executa uma escrita duas vezes.

A correção é fazer o loop começar perguntando à transcrição o que está pendente:

harness.tsTS
export function pending(state: RunState): ToolCall[] {
  const answered = new Set(state.messages.filter((m) => m.role === "tool").map((m) => m.tool_call_id));
  const last = state.messages.at(-1);
  if (last?.role !== "assistant") return [];
  return (last.tool_calls ?? []).filter((c) => !answered.has(c.id));    
}

Cada iteração drena pending primeiro e só pergunta ao modelo quando não há nada pendente. Retomar passa a ser o mesmo caminho de código que o normal, e aprovação também — uma chamada aprovada é simplesmente uma chamada pendente que agora tem permissão para rodar. Mate o processo no meio da tarefa e reinicie:

TEXT
process died after turn 2. tool runs so far: list_files, read_file:errors.log
restored from disk: turns=2  cost=$0.001570  messages=6  status=running
resumed and finished: turns=3  cost=$0.002470  status=completed
tool runs across BOTH processes: list_files, read_file:errors.log

Duas execuções de ferramenta em dois processos para uma tarefa que precisa de duas, e o custo final é idêntico ao da execução que nunca caiu. O custo se acumula ao longo do restart porque estava no estado, não em uma variável.

scan_archive leva três segundos aqui e representa a ferramenta que leva três minutos em produção. Duas coisas faltam enquanto ela roda: o usuário não tem ideia de que algo está acontecendo, e o botão Parar não faz nada.

Ambas têm a mesma correção, que é o AbortSignal do capítulo 14 empurrado um nível mais fundo. O signal não é só para o fetch — ele é passado para dentro da ferramenta, e uma ferramenta bem escrita o respeita:

harness.tsTS
result = await tool.run(JSON.parse(c.function.arguments), {
  signal,                                                                     
  progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});
TEXT
progress: scanned 200 of 1200 files  (t+506 ms)
progress: scanned 400 of 1200 files  (t+1007 ms)
no cancellation:            stopped after 3,015 ms, status=completed
user presses Stop at 1.2 s: stopped after 1,202 ms, status=interrupted, reason="user pressed Stop"

Dois milissegundos do clique até a parada, porque o sleep dentro da ferramenta escuta o mesmo signal que o fetch. Passe-o apenas para fetch e o mesmo botão Parar espera três segundos — a duração da ferramenta — e a execução “cancela” depois que o trabalho que ela estava cancelando já terminou. Cancelamento que não é encanado até o fundo é um spinner que diz a palavra certa.

O harness emite uma linha por evento, e o vocabulário é pequeno o bastante para memorizar: turn, tool_start, tool_progress, tool_result, approval_required, run_stopped.

TEXT
{"runId":"n1","type":"turn","turn":1,"prompt_tokens":204,"completion_tokens":23,"total_tokens":227,"costUsd":0.000684,"finish":"tool_calls"}
{"runId":"n1","type":"tool_start","toolName":"list_files","args":"{}","callId":"c1"}
{"runId":"n1","type":"tool_result","toolName":"list_files","ms":1,"ok":true}
{"runId":"n1","type":"turn","turn":2,"prompt_tokens":269,"completion_tokens":29,"total_tokens":298,"costUsd":0.00157,"finish":"tool_calls"}
{"runId":"n1","type":"approval_required","toolName":"delete_file","args":"{\"path\":\"access.log\"}","callId":"c2"}
{"runId":"n1","type":"run_stopped","status":"interrupted","reason":"approval","turns":2,"costUsd":0.00157}

Três propriedades fazem disso um trace, não logging. Toda linha carrega o run id, então uma execução que atravessa três processos e dois dias é uma consulta. Toda linha turn carrega suas próprias contagens de token e o custo acumulado, então “por que esta execução custou quarenta dólares” pode ser respondido depois do fato, em vez de ser reproduzível apenas em teoria. E run_stopped carrega a razão, que é o campo que transforma um ticket de suporte em uma resposta de uma linha: um agent que parou no orçamento e um agent que caiu parecem idênticos do lado de fora e precisam de respostas opostas.

O capítulo 13 mediu time to first token em hardware seu. O capítulo 14 mediu isso através de um socket. Um agent multiplica isso, e o multiplicador é um número que ninguém escolheu:

TrunN(tmodel+ttools)T_{\text{run}} \approx N \cdot \left( t_{\text{model}} + t_{\text{tools}} \right)

A mesma tarefa de três turnos, mudando apenas a latência do provedor:

latência do provedor por turnotempo de parede, 3 turnos
0 ms15 ms
200 ms615 ms
800 ms2.413 ms

O harness em si contribui quinze milissegundos para uma execução de três turnos. Todo o resto é NN multiplicado por um número que você não controla — definido dentro de um escalonador de serving que agrupa sua requisição com requisições de desconhecidos6 — e NN é escolhido pelo modelo. É por isso que o streaming do capítulo 14 importa mais aqui do que em um chat e ajuda menos: você pode fazer streaming do turno final, e os quatro turnos antes dele são silêncio, a menos que o harness emita progresso. Esse também é todo o argumento para o evento tool_progress acima — em um agent, a unidade honesta de feedback não é o token, é o passo.

O mesmo harness, com um modelo real atrás da porta

Link para a seção: O mesmo harness, com um modelo real atrás da porta

Tudo acima rodou contra um provedor roteirizado, o que prova o harness e não prova nada sobre modelos. Então mude uma linha — a junção do capítulo 14, LLM_BASE_URL — e aponte o mesmo código para um Qwen2.5-0.5B-Instruct local com as mesmas quatro ferramentas. Seis tarefas sobre os mesmos três arquivos:

TEXT
turns=2 tools=1 wall= 15,260ms  Which file mentions a timeout?      -> "The file timeout.txt does not exist..."
turns=2 tools=1 wall= 13,037ms  How many files are in the directory? -> "There are three files..."
turns=2 tools=1 wall= 10,121ms  Read notes.txt and tell me what it says. -> "Remember to rotate your logs."
turns=2 tools=2 wall= 21,290ms  List the files and then read each one.
turns=2 tools=1 wall= 10,698ms  Which file is the largest?          -> "The largest file is access.log."
turns=2 tools=1 wall= 12,490ms  Is there a file about rotating logs?
TOTAL turns=12  toolruns=7  wall=82,896ms  mean turn=6,908ms

Três achados, e o terceiro é a razão de esta seção existir.

Todas as tarefas terminaram em exatamente dois turnos. O limite de turnos nunca disparou, o orçamento nunca disparou, e a única saída do loop foi o modelo produzir prosa. Um modelo de meio bilhão de parâmetros não itera; ele responde no segundo fôlego, tenha ou não o que precisa. A contagem de turnos é uma propriedade do modelo, não do seu loop.

O turno médio levou 6.908 milissegundos, então a tabela de latência acima não é um brinquedo: nesse tamanho, uma execução hipotética de oito turnos é quase um minuto de tempo de parede sem nada na tela.

E as respostas estão erradas. O maior arquivo é errors.log; o modelo listou os arquivos, nunca os leu e nomeou um mesmo assim. A primeira tarefa chutou um nome de arquivo, foi informada de que ele não existia e concluiu. O harness executou perfeitamente em todas as seis execuções. Um harness torna um agent governável, não correto — o capítulo 29 é como você descobre qual dos dois, e o capítulo 30 é o que isso custa quando ninguém descobriu.

Uma ferramenta no catálogo pode ter outra execução por trás dela. A interface é a do capítulo 18 — um schema e um endpoint — e um agent inteiro cabe por trás dela porque essa interface é estreita:

subagent.tsTS
const research: Tool = {
  name: "research",
  description: "Investigate one question and return a short summary.",
  parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"] },
  readOnly: true,
  async run(args, ctx) {
    const child = newRun(RESEARCH_SYSTEM, args.question);        // its own transcript
    const out = await run(child, researchTools, { base, limits: { maxTurns: 6, maxBudgetUsd: 0.05 }, signal: ctx.signal });
    return out.output ?? "no result";
  },
};

Três coisas já estão certas nessas dez linhas, e as três são consequências de decisões tomadas acima: o filho tem sua própria window, então a transcrição do pai recebe um resumo em vez de tudo que o filho leu; ele tem seus próprios limites, então um filho runaway não consegue gastar o orçamento do pai; e ele herda o signal, então um único Parar cancela a árvore. Por que uma window limpa é o ponto, não um efeito colateral, é o capítulo 24; os cinco padrões de orquestração — encadeamento de prompt, roteamento, paralelização, orquestrador-trabalhadores, avaliador-otimizador — e a transferência são o capítulo 25.

Onde estão os frameworks, e por que este curso não usou um

Link para a seção: Onde estão os frameworks, e por que este curso não usou um

Nada acima deve ser lido como argumento contra bibliotecas. Medido em 7 de setembro de 2026, para o mês encerrado em 29 de agosto:7

pacotedownloads naquele mêso que ele oferece
ai (Vercel AI SDK)89.385.860ToolLoopAgent, stopWhen, aprovação de ferramentas, hooks de passo
@anthropic-ai/claude-agent-sdk41.558.352o harness do Claude Code como biblioteca: loop, sessões, hooks, permissões, subagents8
@langchain/langgraph12.812.815o loop como um grafo de estado explícito
langchain11.359.058chains, agents, integrações
@openai/agents6.093.155agents, transferências, guardrails
@mastra/core5.914.502agents, workflows, memória

A razão pela qual este curso escreve o loop à mão em vez de ensinar uma delas é declarada, não implícita, e é mensurável. Nos doze meses até 7 de setembro de 2026, ai publicou 945 versões e passou da major 5 para a major 7, e sua classe de agent ainda é exportada como Experimental_Agent; langchain publicou 132 versões na mesma janela; @openai/agents publicou 83 e ainda está em 0.x, quinze meses depois do primeiro lançamento.7 Um capítulo escrito contra qualquer uma dessas APIs fica velho dentro de uma estação, e este é publicado em trinta e três idiomas, então cada reedição custa a tradução inteira. O que está por baixo de todas elas não se move: um loop, uma regra de parada, um catálogo, um executor, algum estado.

E a implementação de referência concorda com este capítulo sobre a parte que importa. Na versão 7.0.93 de ai, a saída do loop não é um número — é stopWhen, uma lista de predicados, da qual a contagem de passos é apenas um:3

ai-sdk.tsTS
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>;   // exported as stepCountIs

Parar é plural na implementação mais usada deste loop, pela mesma razão que é plural nas cento e noventa e seis linhas acima.

Agora você tem um harness: um loop, um catálogo, um executor, cinco saídas, uma execução persistida, um signal que chega às ferramentas e um trace com um run id em cada linha. Os capítulos 24, 25, 29 e 30 constroem em cima deste arquivo, e os capítulos 26 a 28 em cima do que ele consegue alcançar.

Ele ainda tem um problema, e as medições acima apontaram para ele o tempo todo. Olhe mais uma vez para a tabela runaway: 3.431 input tokens em oito turnos, 337.299 em cem. Olhe para a execução que funciona: 204, 269, 342. Cada turno reenvia a transcrição inteira, então o contexto de um agent se enche com a própria história — e o modelo é pior em usar a ponta distante de uma window longa do que a ponta próxima, razão pela qual um bom agent no turno cinco é um agent confuso no turno quarenta.

Um limite de turnos não corrige isso. Ele só impede você de pagar para assistir acontecer. O que corrige é decidir, em absolutamente todo turno, quais tokens merecem a window: o que compactar, o que mover para uma nota que o agent possa buscar, o que entregar a um subagent com uma window limpa e quais definições de ferramenta valem seu imposto permanente. O capítulo 24 mede para onde a window realmente vai — e a surpresa é que não é para a conversa.


Todo número neste capítulo veio dos dois servidores descritos acima, em Node 22 por uma interface loopback: um provedor roteirizado contando tokens com o encoding o200k_base, e Qwen/Qwen2.5-0.5B-Instruct atrás de um endpoint do mesmo formato, greedy decoding, em CPU. Os custos são calculados a partir de contagens medidas de token nas tarifas que o capítulo 16 leu em 6 de setembro de 2026 — $2.00 por milhão de input tokens e $12.00 por milhão de output — e nenhuma requisição neste capítulo foi para um endpoint pago. As respostas do modelo local são respostas de um modelo pequeno; leia-as como evidência sobre o loop, que é idêntico dos dois jeitos, e não como benchmark do que os modelos atuais fazem.

  1. Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. e Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). O entrelaçamento de rastros de raciocínio e ações que o loop implementa, e a fonte da observação de que agir permite a um modelo “lidar com exceções” — exatamente o que a tabela de erros de ferramenta acima mede.

  2. Sumers, T. R., Yao, S., Narasimhan, K. e Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). O tratamento formal do que o loop acima faz informalmente: componentes modulares de memória, um espaço de ações estruturado que abrange memória interna e ambientes externos, e “um processo generalizado de tomada de decisão para escolher ações”. Leia pelo vocabulário que falta ao termo da indústria — em particular a separação entre memória de trabalho, episódica, semântica e procedural, cuja sombra prática é a tabela de três armazenamentos do capítulo 24.

  3. ai (Vercel AI SDK) versão 7.0.93, publicada em 4 de setembro de 2026; declarações de tipo lidas em cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts em 7 de setembro de 2026. O arquivo de 397 KB contém zero ocorrências da string harness. A classe de agent é declare class ToolLoopAgent, exportada tanto como ToolLoopAgent quanto como Experimental_Agent; declare function isStepCount(stepCount: number) — exportada como stepCountIs — é citada literalmente acima; type StopCondition é mostrada sem seu segundo parâmetro de tipo (RUNTIME_CONTEXT extends Context = Context), que é a única elisão no trecho, assim como o formato de stopWhen?: Arrayable<StopCondition<...>> em generateText e streamText. O mesmo arquivo declara toolApproval, ToolApprovalStatus, prepareStep e repairToolCall, ou seja, a implementação de referência chegou de forma independente a gates de aprovação, preparação por passo e reparo de erros. 2

  4. Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. e Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). O resumo chama o artefato de “evaluation framework” com 2.294 problemas e nunca usa a palavra “harness”; o README do próprio projeto (github.com/SWE-bench/SWE-bench, lido em 7 de setembro de 2026) a usa cinco vezes, sempre como “evaluation harness”, e o ponto de entrada é python -m swebench.harness.run_evaluation. Esse é o outro sentido da palavra: um andaime que mantém o agent parado e o pontua, não o loop que o executa.

  5. Anthropic, Building effective agents, 19 de dezembro de 2024, anthropic.com/engineering/building-effective-agents, lido em 7 de setembro de 2026. O modelo aumentado como bloco de construção, o agent como um LLM “usando ferramentas com base em feedback ambiental em um loop”, e a recomendação de condições de parada “como um número máximo de iterações” para manter o controle. O capítulo 22 cita sua definição na íntegra.

  6. Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. e Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). O outro loop — o escalonador de serving que agrupa sua requisição com requisições de desconhecidos e gerencia o KV cache do capítulo 13. Vale saber que ele existe justamente porque não é seu: a latência que seu harness multiplica é definida dentro dele, e nenhum trabalho no seu loop a move.

  7. Contagens de downloads do registro npm, api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, uma janela explícita em vez da janela móvel last-month, e históricos de release de registry.npmjs.org/<package>; ambos consultados em 7 de setembro de 2026. As contagens de releases são o número de versões publicadas nos doze meses até essa data, incluindo builds canary: ai 945 (mais recente 7.0.93 em 2026-09-04, com as major versions 5, 6 e 7 aparecendo todas dentro da janela), langchain 132 (mais recente 1.5.10 em 2026-08-20), @openai/agents 83 (mais recente 0.17.0 em 2026-08-19, primeira publicação em 2025-06-03). 2

  8. O Claude Agent SDK (@anthropic-ai/claude-agent-sdk) é o harness do Claude Code empacotado como biblioteca — loop de agent, ferramentas integradas de arquivo e shell, gerenciamento de contexto, sessões, hooks, permissões e subagents — documentado em code.claude.com/docs/en/agent-sdk. É a coisa mais próxima de uma descrição publicada de cada mecanismo que este capítulo constrói à mão, e vale ler ao lado da sua própria implementação pelas partes que ele nomeia e que este capítulo apenas sugere.

Pronto para deixar a LIA escolher por você?

Crie com todos os modelos de IA em um só lugar — comece grátis hoje.