Construa um agent harness: o loop e as suas cinco saídas
Um loop de quinze linhas que funciona à primeira, depois quebrado sete vezes de propósito — incluindo uma fuga 77 vezes mais cara.
Nesta página
Comece pela parte honesta, porque mais ninguém o dirá: «harness» é jargão, não uma norma. Não há especificação, comité nem definição de referência. Os quatro artigos que este capítulo cita — ReAct,1 CoALA,2 SWE-bench e vLLM — não usam a palavra uma única vez nos seus resumos. A implementação mais descarregada 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 tipos enviados pela versão 7.0.93.3 O único sítio onde a palavra tem peso quer dizer outra coisa. O SWE-bench diz «harness» cinco vezes no README, sempre como evaluation harness — o andaime contentorizado que aplica um patch e corre os testes — e o seu módulo Python é literalmente swebench.harness.run_evaluation.4
Portanto, duas coisas diferentes partilham um nome. Um evaluation harness mantém o agent imóvel e pontua-o. 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 pelo meio. Este capítulo constrói o segundo, em menos de duzentas linhas de TypeScript, sem framework nenhuma.
O loop em si tem quinze linhas e funciona à primeira tentativa. Tudo o que vem depois é uma forma de sair dele.
Mostrar detalhes
O que este capítulo precisa dos anteriores.
- Capítulo 14 para o cliente: prazos, triagem de estados, cancelamento, chaves de idempotência e a técnica do fornecedor simulado usada aqui outra vez.
- Capítulo 16 para a aritmética: os tokens de entrada crescem com o quadrado da conversa, e as tarifas usadas abaixo são as lidas aí a 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 os erros são context em vez de exceções.
- Capítulo 22 para o loop que este herda, e para as duas definições publicadas de «agent» que discordam entre si.
Aqui não há tensores. Este é o segundo hub de dependências do curso: os capítulos 24, 25, 29 e 30 correm sobre o ficheiro abaixo, e os capítulos 26 a 28 constroem sobre aquilo a que ele consegue chegar.
Um fornecedor que pode programar por script
Ligação para a secção: Um fornecedor que pode programar por scriptO capítulo 14 não podia ser escrito contra um fornecedor real, porque não se pode pedir a um fornecedor real um 429 num momento escolhido. Este capítulo tem o mesmo problema noutra forma: não se pode pedir a um modelo real que fuja, ou que peça a mesma ferramenta duas vezes seguidas, quando quisermos e de forma reprodutível.
Por isso, o primeiro programa é um fornecedor programado por script: um endpoint com a forma de uma API de chat completions cuja resposta é uma função do índice do turno e do que as ferramentas devolveram até aí. Conta tokens com um codificador byte-pair real, por isso o dinheiro abaixo é aritmética e não decoração.
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 desenho. O índice do turno é derivado da conversa, não guardado numa variável, por isso o fornecedor é 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 programado por script que lê a própria transcrição é o mínimo necessário para medir se o harness lhe deu alguma coisa que valha a pena ler.
O catálogo é o do capítulo 18, quatro ferramentas em três ficheiros: list_files, read_file, delete_file — marcada needsApproval — e scan_archive, que é lenta de propósito.
O loop que funciona
Ligação para a secção: O loop que funcionaEis 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 ao fornecedor programado por script 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 ferramentas, um quarto de cêntimo de dólar. Repare na última linha: 204, 269, 342. Cada turno reenviar tudo o que veio antes, que é a fatura quadrática do capítulo 16 a chegar a um sítio onde ninguém escreveu nada. O resto deste capítulo é o que acontece quando essa linha não para de crescer.
Quebra um: a tarefa que nunca acaba
Ligação para a secção: Quebra um: a tarefa que nunca acabaAponte o mesmo loop ao script runaway — um modelo que pede uma ferramenta em todos os turnos e nunca emite prosa — e o return marcado nunca dispara. Não há outra saída. O programa corre até o processo morrer ou o cartão de crédito morrer.
A correção é uma linha, é o primeiro controlo que a literatura recomenda,5 e toda a gente acaba por a escrever. O que quase ninguém faz é medir o que ela vale:
| limite de turnos | chamadas ao modelo | tokens de entrada | 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 em conjunto. Duplicar o limite de 50 para 100 não duplicou o custo; multiplicou-o por 3,7. Os tokens de entrada passaram de 88.649 para 337.299, um fator de 3,8, porque o turno leva consigo todos os turnos anteriores e o total é . Um limite de turnos não é um seletor linear. É um seletor na raiz quadrada do pior caso, razão pela qual subi-lo de 20 para 100 «só por segurança» é uma decisão que vale a pena orçamentar antes de a tomar.
Quebra dois: um limite de turnos não é um limite de dinheiro
Ligação para a secção: Quebra dois: um limite de turnos não é um limite de dinheiroO problema de um limite de turnos é que um turno não tem preço fixo. Vinte turnos numa 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 fatura.
Por isso, o loop conta dinheiro, usando o computeCost do capítulo 16 contra as tarifas aí lidas — $2,00 por milhão de tokens de entrada e $12,00 por milhão de saída, para o modelo com preço usado 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);O mesmo script em fuga, sem limite de turnos nenhum, três orçamentos:
| orçamento | turnos atingidos | 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 de cada vez, que é precisamente o objetivo: limita aquilo que importa ao operador e deixa a contagem de turnos cair onde a transcrição a põe. Segundo, todas as linhas derrapam. O orçamento era $0,010 e foram gastos $0,010780, porque a verificação corre antes de um turno e o preço de um turno só é conhecido quando ele acaba. Não se consegue limitar o gasto de forma exata; consegue-se limitá-lo até ao custo de um turno. Diga isso na interface em vez de fingir, e ponha a verificação antes da chamada para a derrapagem ser de um turno e não de dois.
Cinco formas de sair do loop, não uma
Ligação para a secção: Cinco formas de sair do loop, não umaNesta altura, o loop tem três saídas, e a forma do resto do capítulo já se vê. 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 deixou de pedir | o modelo | ler a resposta |
| limite de turnos | você, antecipadamente | subir o limite ou aceitar um resultado parcial |
| orçamento esgotado | você, antecipadamente | aprovar mais dinheiro ou aceitar um resultado parcial |
| um erro que não pode repetir | o fornecedor ou uma ferramenta | corrigir o deployment; a triagem do capítulo 14 decide |
| um humano interveio | uma pessoa | esperar por um veredicto e depois retomar |
Colapsar isto num booleano é o erro de desenho mais comum neste ficheiro, e é caro de uma forma específica: três das cinco são retomáveis e duas não. Um agent que atingiu o limite de turnos tem uma transcrição válida, um resultado parcial real e um passo seguinte; um agent que recebeu um 401 não tem nada disso. Por isso, o harness regista 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
Ligação para a secçã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 da ferramenta, em vez de o lançar, e o modelo normalmente corrige-se. Eis o número.
Uma falha, três políticas. O modelo programado por script adivinha um ficheiro 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 utilizador recebeu |
|---|---|---|---|---|
| lança-o para fora do loop | 1 | 1 | $0,000756 | um stack trace |
devolve Error: the tool failed. | 2 | 1 | $0,001462 | «Não consegui ler o ficheiro, por isso não sei.» |
| devolve 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 das bases de código faz na prática: o erro foi apanhado, o loop sobreviveu, disseram ao modelo que alguma coisa falhou e não o quê, e ele desistiu educadamente. A diferença entre as linhas dois e três não é tratamento de erros. É uma frase escrita para um leitor.
Por isso, o harness trata uma ferramenta que lança como dados e faz da formulação uma 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 avisou sobre o outro lado, e esse também tem um preço. Aponte o loop a uma ferramenta que falha por uma razão que nenhuma mensagem consegue corrigir — uma leitura que o processo não tem autorização para fazer — e o modelo tenta outra vez 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 ter sucesso, 5,2 vezes o custo da execução que recuperou de uma falha corrigível, e nada no fim. Erros são context; um erro permanente é context que envenena o resto da execução. A distinção é a triagem de estados 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 agir deve parar a execução com uma razão. O limite de turnos é o que hoje se interpõe entre si e o segundo caso, o que é um piso e não uma correção.
Quebra quatro: a mesma chamada, duas vezes
Ligação para a secção: Quebra quatro: a mesma chamada, duas vezesAgora a falha que a maior parte das pessoas assume que não pode acontecer. Os modelos repetem-se. Peça a qualquer loop para correr tempo suficiente e verá a ferramenta idêntica, com argumentos idênticos, em dois turnos consecutivos.
Medido contra a base da mesma tarefa sem a 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 resultados em ferramentas só de leitura | 3 | 1 | $0,002446 |
A chamada duplicada custou mais $0,001050, um aumento de 75 %, e aqui está a parte que surpreende as pessoas: pôr o resultado em cache não recuperou nada disso. A deduplicação poupou a execução da ferramenta e não o turno, porque quando o seu código deteta a repetição o modelo já foi pago por a pedir. A poupança é real quando a ferramenta é lenta, limitada por rate limit ou faturada por chamada — e é zero na rubrica que cresceu.
Há uma versão pior. Aplique a mesma 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 cognoscível. O protocolo diz que são duas chamadas: transportam dois valores tool_call_id diferentes. Os argumentos dizem que talvez sejam uma. Um harness que decide comparando strings de argumentos há de um dia engolir a segunda de duas cobranças idênticas e intencionais — e o capítulo 14 já nomeou o único mecanismo que resolve isto 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 transportar uma, o padrão defensável é o portão só de leitura acima: pôr leituras em cache, executar escritas e deixar a idempotência própria da escrita tratar 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 alguma coisa
Ligação para a secção: Quebra cinco: ele apaga alguma coisaO script destructive lista os ficheiros e depois pede para apagar um que a tarefa nunca mencionou. Nada no loop até aqui o impediria.
Uma ferramenta marcada needsApproval não falha nem prossegue. Para a execução e devolve o controlo, com tudo o 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 para ser um return em vez de um callback está na secção seguinte: entre a paragem e o veredicto, o processo pode já nem existir.
Mas primeiro, a medição que ninguém espera. Uma rejeição não é a ausência de um resultado — a transcrição tem uma ranhura indexada por tool_call_id e alguma coisa tem de entrar nela. Corra a mesma rejeição duas vezes, mudando apenas o que essa alguma coisa 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 dizem ao utilizador que foi. O sistema de permissões funcionou na perfeição; o relatório é mentira. É o mesmo mecanismo da tabela de erros de ferramentas, a chegar a um sítio muito mais importante — um humano disse não, a ação foi bloqueada corretamente, e o resumo do agent contradiz a realidade porque a recusa nunca foi escrita onde o modelo lê. A regra que daí sai é curta: seja qual for a decisão do seu código sobre uma chamada de ferramenta, escreva a decisão na transcrição por palavras. O capítulo 30 volta a isto pelo lado da segurança, onde é a diferença entre um rasto de auditoria e ficção.
Quebra seis: o processo morre
Ligação para a secção: Quebra seis: o processo morreUma aprovação demora minutos ou horas. Um deploy demora segundos. Se a execução vive numa variável local dentro de um pedido HTTP, cada reinício é uma execução perdida e cada aprovação é uma corrida.
Por isso, a execução não é uma closure. É um objeto simples serializável — mensagens, contagem de turnos, custo, estado, interrupção, a lista de ids de chamadas aprovadas — e o loop é uma função pura sobre ele. Essa única restrição é o que torna a 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 é guardar. É o que acontece no caminho de volta, e a resposta ingénua cobra-lhe duas vezes. Se o processo morreu depois de o modelo pedir uma ferramenta mas antes de o resultado ser escrito, uma retoma que começa por chamar o modelo outra vez paga por um turno que já tem — e se começa por voltar a correr as ferramentas, executa uma escrita duas vezes.
A correção é fazer o loop começar por perguntar à 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 primeiro pending e só pergunta ao modelo quando não há nada pendente. A retoma passa a ser o mesmo caminho de código que o normal, e a aprovação também — uma chamada aprovada é simplesmente uma chamada pendente que agora pode correr. Mate o processo a meio da tarefa e reinicie-o:
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 ferramentas 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 acumula ao longo do reinício porque estava no estado, não numa variável.
Quebra sete: três minutos de silêncio
Ligação para a secção: Quebra sete: três minutos de silêncioscan_archive demora três segundos aqui e representa a ferramenta que demora três minutos em produção. Faltam duas coisas enquanto ela corre: o utilizador não faz ideia de que algo está a acontecer, e o botão Parar não faz nada.
Ambas têm a mesma correção, e é o AbortSignal do capítulo 14 empurrado um nível mais fundo. O sinal não é só para o fetch — é passado para dentro da ferramenta, e uma ferramenta bem escrita respeita-o:
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é à paragem, porque o sleep dentro da ferramenta escuta o mesmo sinal que o fetch. Enfie-o apenas em fetch e o botão Parar idêntico espera três segundos — a duração da ferramenta — e a execução «cancela» depois de o trabalho que estava a cancelar já ter acabado. Cancelamento que não é canalizado até ao fundo é um spinner que diz a palavra certa.
O trace, e porque não é um log
Ligação para a secção: O trace, e porque não é um logO harness emite uma linha por evento, e o vocabulário é pequeno o suficiente 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 disto um trace e não logging. Cada linha transporta o run id, por isso uma execução que atravessa três processos e dois dias é uma consulta. Cada linha turn transporta as suas próprias contagens de tokens e o custo acumulado, por isso «porque é que esta execução custou quarenta dólares» pode ser respondido depois do facto, em vez de ser reproduzível apenas em teoria. E run_stopped transporta a razão, que é o campo que transforma um ticket de suporte numa resposta de uma linha: um agent que parou no orçamento e um agent que crashou parecem idênticos por fora e precisam de respostas opostas.
A aritmética da latência
Ligação para a secção: A aritmética da latênciaO capítulo 13 mediu o tempo até ao primeiro token em hardware seu. O capítulo 14 mediu-o através de um socket. Um agent multiplica-o, e o multiplicador é um número que ninguém escolheu:
A mesma tarefa de três turnos, mudando apenas a latência do fornecedor:
| latência do fornecedor por turno | relógio de parede, 3 turnos |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2.413 ms |
O próprio harness contribui quinze milissegundos para uma execução de três turnos. Tudo o resto é multiplicado por um número que não controla — definido dentro de um scheduler de serving que agrupa o seu pedido com pedidos de desconhecidos6 — e é escolhido pelo modelo. É por isto que o streaming do capítulo 14 importa mais aqui do que num chat e ajuda menos: pode fazer streaming do turno final, e os quatro turnos antes dele são silêncio a menos que o harness emita progresso. É também o argumento inteiro para o evento tool_progress acima — num agent, a unidade honesta de feedback não é o token, é o passo.
O mesmo harness, um modelo real atrás da porta
Ligação para a secção: O mesmo harness, um modelo real atrás da portaTudo acima correu contra um fornecedor programado por script, o que prova o harness e não prova nada sobre modelos. Portanto, mude uma linha — a costura do capítulo 14, LLM_BASE_URL — e aponte o código idêntico a um Qwen2.5-0.5B-Instruct local com as mesmas quatro ferramentas. Seis tarefas sobre os mesmos três ficheiros:
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 conclusões, e a terceira é a razão pela qual esta secção existe.
Todas as tarefas acabaram exatamente em 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 milhar de milhão de parâmetros não itera; responde no segundo fôlego, tenha ou não aquilo de que precisa. A contagem de turnos é uma propriedade do modelo, não do seu loop.
O turno médio demorou 6.908 milissegundos, por isso a tabela de latência acima não é um brinquedo: neste tamanho, uma execução hipotética de oito turnos é quase um minuto de relógio de parede sem nada no ecrã.
E as respostas estão erradas. O maior ficheiro é errors.log; o modelo listou os ficheiros, nunca os leu e nomeou um na mesma. A primeira tarefa adivinhou um nome de ficheiro, recebeu a indicação de que não existia e concluiu. O harness executou impecavelmente nas seis execuções. Um harness torna um agent governável, não correto — o capítulo 29 é como descobre qual dos dois, e o capítulo 30 é o que custa quando ninguém o fez.
Subagents, nomeados aqui e cobrados mais tarde
Ligação para a secção: Subagents, nomeados aqui e cobrados mais tardeUma ferramenta no catálogo pode ter outra execução por trás. 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 todas as três são consequências das decisões tomadas acima: o filho tem a sua própria janela, por isso a transcrição do pai recebe um resumo em vez de tudo o que o filho leu; tem os seus próprios limites, por isso um filho em fuga não consegue gastar o orçamento do pai; e herda o sinal, por isso um Parar cancela a árvore. Porque é que uma janela limpa é o ponto e não um efeito secundário é o capítulo 24; os cinco padrões de orquestração — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — e o handoff são o capítulo 25.
Onde estão as frameworks, e porque este curso não usou nenhuma
Ligação para a secção: Onde estão as frameworks, e porque este curso não usou nenhumaNada do que está acima deve ser lido como um argumento contra bibliotecas. Medido a 7 de setembro de 2026, para o mês terminado a 29 de agosto:7
| pacote | downloads nesse mês | o que lhe dá |
|---|---|---|
ai (Vercel AI SDK) | 89.385.860 | ToolLoopAgent, stopWhen, aprovação de ferramentas, hooks de passos |
@anthropic-ai/claude-agent-sdk | 41.558.352 | o Claude Code harness como biblioteca: loop, sessões, hooks, permissões, subagents8 |
@langchain/langgraph | 12.812.815 | o loop como grafo de estados explícito |
langchain | 11.359.058 | chains, agents, integrações |
@openai/agents | 6.093.155 | agents, handoffs, 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 sugerida, 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 a sua classe de agent continua a ser exportada como Experimental_Agent; langchain publicou 132 versões na mesma janela; @openai/agents publicou 83 e continua em 0.x, quinze meses depois do primeiro lançamento.7 Um capítulo escrito contra qualquer uma dessas APIs fica velho numa estação, e este é publicado em trinta e três línguas, por isso cada reedição custa a tradução inteira. O que está por baixo de todas elas não se mexe: um loop, uma regra de paragem, um catálogo, um executor, algum estado.
E a implementação de referência concorda com este capítulo na 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, dos quais uma 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 por que é plural nas cento e noventa e seis linhas acima.
Para onde isto segue
Ligação para a secção: Para onde isto segueAgora tem um harness: um loop, um catálogo, um executor, cinco formas de sair, uma execução persistida, um sinal que chega às ferramentas e um trace com um run id em cada linha. Os capítulos 24, 25, 29 e 30 constroem sobre este ficheiro, e os capítulos 26 a 28 sobre aquilo a que ele consegue chegar.
Falta-lhe um problema, e as medições acima têm estado a apontar para ele o tempo todo. Olhe outra vez para a tabela da fuga: 3.431 tokens de entrada aos oito turnos, 337.299 aos cem. Olhe para a execução que funciona: 204, 269, 342. Cada turno reenviar a transcrição inteira, por isso o context de um agent enche-se com a própria história — e o modelo é pior a usar o extremo longínquo de uma janela longa do que o extremo próximo, que é por isso que um bom agent no turno cinco é um agent confuso no turno quarenta.
Um limite de turnos não corrige isso. Só o impede de pagar para ver acontecer. O que o corrige é decidir, em todos os turnos, que tokens merecem a janela: o que compactar, o que mover para uma nota que o agent pode ir buscar, o que entregar a um subagent com uma janela limpa e que definições de ferramentas valem o seu imposto permanente. O capítulo 24 mede para onde a janela vai realmente — e a surpresa é que não é para a conversa.
Fontes e método
Ligação para a secção: Fontes e métodoTodos os números deste capítulo saíram dos dois servidores descritos acima, em Node 22 sobre uma interface loopback: um fornecedor programado por script a contar tokens com a codificação o200k_base, e Qwen/Qwen2.5-0.5B-Instruct atrás de um endpoint da mesma forma, greedy decoding, em CPU. Os custos são calculados a partir de contagens medidas de tokens às tarifas que o capítulo 16 leu a 6 de setembro de 2026 — $2,00 por milhão de tokens de entrada e $12,00 por milhão de saída — e nenhum pedido neste capítulo foi para um endpoint pago. As respostas do modelo local são respostas de um modelo pequeno; leia-as como prova sobre o loop, que é idêntico de qualquer forma, e não como benchmark do que os modelos atuais fazem.
Referências
Ligação para a secçã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 traces 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» — que é exatamente o que a tabela de erros de ferramentas 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 de memória modulares, 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-o 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 procedimental, cuja sombra prática é a tabela de três stores do capítulo 24. ↩
-
ai(Vercel AI SDK) versão 7.0.93, publicada a 4 de setembro de 2026; declarações de tipos lidas emcdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.tsa 7 de setembro de 2026. O ficheiro de 397 KB contém zero ocorrências da stringharness. A classe de agent édeclare class ToolLoopAgent, exportada tanto comoToolLoopAgentcomoExperimental_Agent;declare function isStepCount(stepCount: number)— exportado comostepCountIs— é citado literalmente acima;type StopConditioné mostrado sem o seu segundo parâmetro de tipo (RUNTIME_CONTEXT extends Context = Context), que é a única elisão no excerto, tal como a forma destopWhen?: Arrayable<StopCondition<...>>emgenerateTextestreamText. O mesmo ficheiro declaratoolApproval,ToolApprovalStatus,prepareSteperepairToolCall, ou seja, a implementação de referência chegou independentemente a portões de aprovação, preparação por passo e reparação 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 ao artefacto uma «evaluation framework» de 2.294 problemas e nunca usa a palavra «harness»; o README do próprio projeto (
github.com/SWE-bench/SWE-bench, lido a 7 de setembro de 2026) usa-a 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 imóvel 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 a 7 de setembro de 2026. O modelo aumentado como bloco de construção, o agent como um LLM «using tools based on environmental feedback in a loop», e a recomendação de condições de paragem «such as a maximum number of iterations» para manter controlo. O capítulo 22 cita a definição completa. ↩ -
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 scheduler de serving que agrupa o seu pedido com pedidos de desconhecidos e gere a KV cache do capítulo 13. Vale a pena saber que existe precisamente porque não é seu: a latência que o seu harness multiplica é definida lá dentro, e nenhum trabalho no seu loop a desloca. ↩
-
Contagens de downloads do registo npm,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, uma janela explícita em vez da janela deslizantelast-month, e históricos de releases deregistry.npmjs.org/<package>; ambos consultados a 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 versões major 5, 6 e 7 todas a aparecer dentro da janela),langchain132 (mais recente 1.5.10 em 2026-08-20),@openai/agents83 (mais recente 0.17.0 em 2026-08-19, publicado pela primeira vez em 2025-06-03). ↩ ↩2 -
O Claude Agent SDK (
@anthropic-ai/claude-agent-sdk) é o Claude Code harness empacotado como biblioteca — agent loop, ferramentas de ficheiros e shell integradas, gestão de context, sessões, hooks, permissões e subagents — documentado emcode.claude.com/docs/en/agent-sdk. É a coisa mais próxima de um relato publicado de cada mecanismo que este capítulo constrói à mão, e vale a pena lê-lo ao lado da sua própria implementação pelas partes que ele nomeia e que este capítulo apenas sugere. ↩