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.
Um provedor que você pode roteirizar
Link para a seção: Um provedor que você pode roteirizarO 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.
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.
O loop que funciona
Link para a seção: O loop que funcionaAqui está a ideia inteira, antes de qualquer uma das partes que a tornam sobrevivível.
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:
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, 342Trê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.
Quebra um: a tarefa que nunca termina
Link para a seção: Quebra um: a tarefa que nunca terminaAponte 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 turnos | chamadas ao modelo | input tokens | custo |
|---|---|---|---|
| 8 | 8 | 3.431 | $0.009070 |
| 20 | 20 | 16.259 | $0.038038 |
| 50 | 50 | 88.649 | $0.191098 |
| 100 | 100 | 337.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 carrega todos os turnos anteriores consigo e o total é . 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 dinheiroO 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:
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çamento | turnos alcançados | gasto real |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $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.
Cinco formas de sair do loop, não uma
Link para a seção: Cinco formas de sair do loop, não umaA 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 termina | quem decidiu | o que o chamador deve fazer |
|---|---|---|
| o modelo parou de pedir | o modelo | leia a resposta |
| limite de turnos | você, antes | aumente o limite ou aceite um resultado parcial |
| orçamento esgotado | você, antes | aprove mais dinheiro ou aceite um resultado parcial |
| um erro que você não pode tentar de novo | o provedor ou uma ferramenta | corrija o deployment; a triagem do capítulo 14 decide |
| um humano interveio | uma pessoa | aguarde 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:
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 };Quebra três: uma ferramenta falha
Link para a seção: Quebra três: uma ferramenta falhaO 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 erro | turnos | execuções de ferramenta | custo | o que o usuário recebeu |
|---|---|---|---|---|
| joga para fora do loop | 1 | 1 | $0.000756 | um stack trace |
retorna Error: the tool failed. | 2 | 1 | $0.001462 | “Não consegui ler o arquivo, então não sei.” |
| retorna o que realmente aconteceu | 4 | 3 | $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:
} 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:
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.
Quebra quatro: a mesma chamada, duas vezes
Link para a seção: Quebra quatro: a mesma chamada, duas vezesAgora 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:
| turnos | execuções de ferramenta | custo | |
|---|---|---|---|
| a tarefa, sem repetição | 2 | 1 | $0.001396 |
| a mesma tarefa, uma chamada repetida | 3 | 2 | $0.002446 |
| repetida, com cache de resultado em ferramentas somente leitura | 3 | 1 | $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:
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.
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;
}Quebra cinco: ele apaga algo
Link para a seção: Quebra cinco: ele apaga algoO 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:
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) });
}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:
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.
Quebra seis: o processo morre
Link para a seção: Quebra seis: o processo morreUma 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:
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:
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:
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.logDuas 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.
Quebra sete: três minutos de silêncio
Link para a seção: Quebra sete: três minutos de silêncioscan_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:
result = await tool.run(JSON.parse(c.function.arguments), {
signal,
progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});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 trace, e por que ele não é um log
Link para a seção: O trace, e por que ele não é um logO 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.
{"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.
A aritmética da latência
Link para a seção: A aritmética da latênciaO 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:
A mesma tarefa de três turnos, mudando apenas a latência do provedor:
| latência do provedor por turno | tempo de parede, 3 turnos |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2.413 ms |
O harness em si contribui quinze milissegundos para uma execução de três turnos. Todo o resto é 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 é 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 portaTudo 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:
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,908msTrê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.
Subagents, nomeados aqui e cobrados depois
Link para a seção: Subagents, nomeados aqui e cobrados depoisUma 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:
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 umNada 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
| pacote | downloads naquele mês | o que ele oferece |
|---|---|---|
ai (Vercel AI SDK) | 89.385.860 | ToolLoopAgent, stopWhen, aprovação de ferramentas, hooks de passo |
@anthropic-ai/claude-agent-sdk | 41.558.352 | o harness do Claude Code como biblioteca: loop, sessões, hooks, permissões, subagents8 |
@langchain/langgraph | 12.812.815 | o loop como um grafo de estado explícito |
langchain | 11.359.058 | chains, agents, integrações |
@openai/agents | 6.093.155 | agents, transferências, guardrails |
@mastra/core | 5.914.502 | agents, 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
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>; // exported as stepCountIsParar é plural na implementação mais usada deste loop, pela mesma razão que é plural nas cento e noventa e seis linhas acima.
Para onde isso vai agora
Link para a seção: Para onde isso vai agoraAgora 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.
Fontes e método
Link para a seção: Fontes e métodoTodo 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.
Referências
Link para a seção: Referências-
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. ↩
-
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. ↩
-
ai(Vercel AI SDK) versão 7.0.93, publicada em 4 de setembro de 2026; declarações de tipo lidas emcdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.tsem 7 de setembro de 2026. O arquivo de 397 KB contém zero ocorrências da stringharness. A classe de agent édeclare class ToolLoopAgent, exportada tanto comoToolLoopAgentquanto comoExperimental_Agent;declare function isStepCount(stepCount: number)— exportada comostepCountIs— é 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 destopWhen?: Arrayable<StopCondition<...>>emgenerateTextestreamText. O mesmo arquivo declaratoolApproval,ToolApprovalStatus,prepareSteperepairToolCall, 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 -
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. ↩ -
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. ↩ -
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. ↩
-
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óvellast-month, e históricos de release deregistry.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:ai945 (mais recente 7.0.93 em 2026-09-04, com as major versions 5, 6 e 7 aparecendo todas dentro da janela),langchain132 (mais recente 1.5.10 em 2026-08-20),@openai/agents83 (mais recente 0.17.0 em 2026-08-19, primeira publicação em 2025-06-03). ↩ ↩2 -
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 emcode.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. ↩