Constrúe un agent harness: o loop e as súas cinco saídas
Un loop de quince liñas que funciona á primeira, roto sete veces a propósito, incluíndo unha fuga que custou 77 veces máis.
Nesta páxina
Empeza pola parte honesta, porque ninguén máis o vai dicir: "harness" é xerga, non un estándar. Non hai especificación, nin comité, nin definición de referencia. Os catro artigos que cita este capítulo — ReAct,1 CoALA,2 SWE-bench e vLLM — non usan a palabra nin unha soa vez nos seus resumos. A implementación máis descargada desa cousa, o paquete ai de Vercel con 89,4 millóns de descargas ao mes, tampouco a usa: a cadea harness aparece cero veces nos 397 KB de declaracións de tipos enviados pola versión 7.0.93.3 O único lugar onde a palabra si soporta peso significa outra cousa completamente distinta. SWE-bench di "harness" cinco veces no seu README, sempre como evaluation harness — o andamio contenerizado que aplica un parche e executa os tests — e o seu módulo de Python é literalmente swebench.harness.run_evaluation.4
Así que dúas cousas distintas comparten nome. Un evaluation harness mantén quieto o agent e puntúao. Un agent harness é o programa que executa o agent: chama o modelo, executa o que o modelo pide, decide cando parar e conserva o estado entre medias. Este capítulo constrúe o segundo, en menos de duascentas liñas de TypeScript, sen ningún framework.
O loop en si ten quince liñas e funciona no primeiro intento. Todo o que vén despois é unha forma de saír del.
Mostrar detalles
O que este capítulo precisa dos anteriores.
- Capítulo 14 para o cliente: prazos límite, triage de estado, cancelación, claves de idempotencia e a técnica do provedor simulado que se usa de novo aquí.
- Capítulo 16 para a aritmética: os input tokens medran co cadrado da conversa, e as tarifas usadas abaixo son as que se leron alí o 6 de setembro de 2026.
- Capítulo 18 para o catálogo de ferramentas: un schema que o modelo ve, un endpoint que nunca ve, e a regra de que os erros son context en vez de excepcións.
- Capítulo 22 para o loop que este herda, e para as dúas definicións publicadas de "agent" que discrepan entre si.
Aquí non hai tensores. Este é o segundo nodo de dependencias do curso: os capítulos 24, 25, 29 e 30 executan sobre o ficheiro de abaixo, e do 26 ao 28 constrúen sobre o que pode alcanzar.
Un provedor que podes guionizar
Ligazón á sección: Un provedor que podes guionizarO capítulo 14 non se podía escribir contra un provedor real, porque non podes pedirlle un 429 nun momento elixido. Este capítulo ten o mesmo problema con outra forma: non podes pedirlle a un modelo real que se descontrole, ou que solicite a mesma ferramenta dúas veces seguidas, baixo demanda e de forma reproducible.
Así que o primeiro programa é un provedor guionizado: un endpoint coa forma dunha API de chat completions cuxa resposta é función do índice da quenda e do que as ferramentas devolveron ata o momento. Conta tokens cun codificador byte-pair real, así que o diñeiro de abaixo é aritmética e non decoración.
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);Dúas liñas levan o deseño. O índice da quenda derívase da conversa, non se garda nunha variable, así que o provedor non ten estado e unha execución pódese matar e retomar contra el. E recover le os resultados das ferramentas antes de decidir: un modelo guionizado que le a súa propia transcrición é o mínimo necesario para medir se o harness lle deu algo que valese a pena ler.
O catálogo é o do capítulo 18, catro ferramentas en tres ficheiros: list_files, read_file, delete_file — marcado como needsApproval — e scan_archive, que é lento a propósito.
O loop que funciona
Ligazón á sección: O loop que funcionaAquí está a idea completa, antes de calquera das partes que a fan sobrevivible.
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 });
}
}Apúntao ao provedor guionizado e fai exactamente o que parece que fai:
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, 342Tres quendas, dúas execucións de ferramentas, un cuarto de centavo de dólar estadounidense. Fíxate na última liña: 204, 269, 342. Cada quenda reenvía todo o anterior, que é a factura cuadrática do capítulo 16 chegando a un lugar onde ninguén escribiu nada. O resto deste capítulo é o que pasa cando esa liña non deixa de medrar.
Rotura un: a tarefa que nunca remata
Ligazón á sección: Rotura un: a tarefa que nunca remataApunta o mesmo loop ao script runaway — un modelo que pide unha ferramenta en cada quenda e nunca emite prosa — e o return marcado nunca se dispara. Non hai outra saída. O programa execútase ata que morre o proceso ou a tarxeta de crédito.
A corrección é unha liña, é o primeiro control que recomenda a literatura,5 e todo o mundo acaba escribíndoa. O que case ninguén fai é medir o que vale:
| turn cap | 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 |
Le as dúas últimas filas xuntas. Duplicar o cap de 50 a 100 non duplicou o custo; multiplicouno por 3,7. Os input tokens pasaron de 88.649 a 337.299, un factor de 3,8, porque a quenda leva consigo todas as quendas anteriores e o total é . Un turn cap non é un selector lineal. É un selector sobre a raíz cadrada do teu peor caso, por iso subilo de 20 a 100 "só por seguridade" é unha decisión que paga a pena poñer prezo antes de tomala.
Rotura dous: un cap de quendas non é un cap de diñeiro
Ligazón á sección: Rotura dous: un cap de quendas non é un cap de diñeiroO problema dun turn cap é que unha quenda non ten prezo fixo. Vinte quendas sobre unha transcrición curta custaron $0.038 arriba. Vinte quendas cun catálogo de 200 ferramentas, un conxunto de documentos recuperados e corenta mensaxes de historial custan centos de veces máis, e o cap non o sabe. O que o operador quere limitar é a factura.
Así que o loop conta diñeiro, usando o computeCost do capítulo 16 contra as tarifas lidas alí — $2.00 por millón de input tokens e $12.00 por millón de output, para o modelo usado nos prezos ao longo de todo este 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 descontrolado, sen turn cap ningún, tres orzamentos:
| orzamento | quendas alcanzadas | gasto real |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $0.205398 |
Hai dúas cousas que paga a pena nomear. Primeiro, o orzamento compra un número distinto de quendas cada vez, que é a idea: limita a cousa que lle importa ao operador e deixa que o reconto de quendas caia onde o poña a transcrición. Segundo, todas as filas se pasan. O orzamento era $0.010 e gastáronse $0.010780, porque a comprobación execútase antes dunha quenda e o prezo dunha quenda non se sabe ata que remata. Non podes limitar o gasto exactamente; podes limitalo a unha marxe dun custo de quenda. Dígoo na interface en vez de finxir, e pon a comprobación antes da chamada para que o exceso sexa dunha quenda e non de dúas.
Cinco formas de saír do loop, non unha
Ligazón á sección: Cinco formas de saír do loop, non unhaA estas alturas o loop ten tres saídas, e a forma do resto do capítulo xa se ve. Unha execución en produción remata exactamente dunha de cinco maneiras, e non son variacións da mesma cousa:
| como remata | quen decidiu | que debería facer o chamador |
|---|---|---|
| o modelo deixou de pedir | o modelo | ler a resposta |
| turn cap | ti, de antemán | subir o cap ou aceptar un resultado parcial |
| orzamento esgotado | ti, de antemán | aprobar máis diñeiro ou aceptar un resultado parcial |
| un erro que non podes reintentar | o provedor ou unha ferramenta | arranxar o deployment; decide o triage do capítulo 14 |
| interveu unha persoa | unha persoa | agardar un veredicto e logo retomar |
Colapsar isto nun booleano é o erro de deseño máis común neste ficheiro, e é caro dunha forma concreta: tres das cinco son retomables e dúas non. Un agent que bateu co seu turn cap ten unha transcrición válida, un resultado parcial real e un seguinte paso; un agent que recibiu un 401 non ten nada diso. Así que o harness rexistra a razón como datos:
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 };Rotura tres: unha ferramenta falla
Ligazón á sección: Rotura tres: unha ferramenta fallaO capítulo 18 rematou cunha afirmación sen número: pásalle o erro dunha ferramenta de volta ao modelo como resultado de ferramenta en vez de lanzalo, e normalmente o modelo arránxase só. Aquí está o número.
Un fallo, tres políticas. O modelo guionizado adiviña un ficheiro que non existe; a ferramenta lanza no such file: timeout.log. Call list_files to see what exists.
| o que fai o harness co erro | quendas | execucións de ferramentas | custo | o que recibiu o usuario |
|---|---|---|---|---|
| bótao fóra do loop | 1 | 1 | $0.000756 | un stack trace |
devolve Error: the tool failed. | 2 | 1 | $0.001462 | "Non puiden ler o ficheiro, así que non o sei." |
| devolve o que pasou de verdade | 4 | 3 | $0.003550 | "errors.log menciona un timeout." |
A terceira fila custa 4,7 veces a primeira e é a única que responde á pregunta. E a segunda fila é a interesante, porque é o que fan a maioría dos codebases: capturouse o erro, o loop sobreviviu, ao modelo díxoselle que algo fallou e non que, e rendeuse con educación. A diferenza entre as filas dúas e tres non é tratamento de erros. É unha frase escrita para unha persoa lectora.
Polo tanto, o harness trata unha ferramenta que lanza como datos, e converte a redacción nunha 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 tamén advertiu sobre o outro lado, e tamén ten un prezo. Apunta o loop a unha ferramenta que falla por unha razón que ningunha mensaxe pode arranxar — unha lectura que o proceso non ten permitido realizar — e o modelo reinténtaa 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=""Once execucións idénticas dunha chamada que non pode saír ben, 5,2 veces o custo da execución que se recuperou dun fallo arranxable, e nada ao final. Os erros son context; un erro permanente é context que envelena o resto da execución. A distinción é o triage de estado do capítulo 14 subido unha capa: un erro sobre o que o modelo pode actuar volve á transcrición, e un erro sobre o que non pode debería deter a execución cunha razón. O turn cap é o que se interpón hoxe entre ti e o segundo caso, e iso é un chan, non unha solución.
Rotura catro: a mesma chamada, dúas veces
Ligazón á sección: Rotura catro: a mesma chamada, dúas vecesAgora o fallo que a maioría da xente asume que non pode pasar. Os modelos repítense. Pídelle a calquera loop que execute tempo suficiente e verás a mesma ferramenta cos mesmos argumentos en dúas quendas consecutivas.
Medido contra a liña base da mesma tarefa sen repetición:
| quendas | execucións de ferramentas | custo | |
|---|---|---|---|
| a tarefa, sen repetición | 2 | 1 | $0.001396 |
| a mesma tarefa, unha chamada repetida | 3 | 2 | $0.002446 |
| repetida, cunha caché de resultados en ferramentas de só lectura | 3 | 1 | $0.002446 |
A chamada duplicada custou $0.001050 extra, un aumento do 75 %, e aquí vén a parte que sorprende a xente: cachear o resultado non recuperou nada diso. A deduplicación aforrou a execución da ferramenta e non a quenda, porque cando o teu código detecta a repetición o modelo xa cobrou por pedila. O aforro é real cando a ferramenta é lenta, ten límites de taxa ou se factura por chamada — e é cero na liña de custo que medrou.
Hai unha versión peor. Aplica a mesma caché a unha ferramenta que escribe, e a segunda chamada silenciosamente non 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"]Cal desas é correcta? Ningunha, de forma cognoscible. O protocolo di que son dúas chamadas: levan dous valores tool_call_id distintos. Os argumentos din que poderían ser unha. Un harness que decide comparando cadeas de argumentos acabará tragando a segunda de dúas cobranças idénticas e intencionadas — e o capítulo 14 xa nomeou o único mecanismo que resolve isto honestamente: unha clave de idempotencia xerada por operación lóxica pola capa que sabe o que é a operación. Ata que a ferramenta leve unha, o valor por defecto defendible é a porta de só lectura de arriba: cachea lecturas, executa escrituras e deixa que a idempotencia propia da escritura se ocupe 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;
}Rotura cinco: elimina algo
Ligazón á sección: Rotura cinco: elimina algoO script destructive lista os ficheiros e logo pide eliminar un que a tarefa nunca mencionou. Nada no loop ata agora o detería.
Unha ferramenta marcada como needsApproval non falla e non continúa. Detén a execución e devolve o control, con todo o que unha persoa 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."Ese é todo o mecanismo, e a razón pola que é un return en vez dun callback é a seguinte sección: entre a parada e o veredicto, o proceso pode deixar de existir.
Pero antes, a medición que ninguén espera. Un rexeitamento non é a ausencia dun resultado: a transcrición ten un oco con clave tool_call_id e algo ten que entrar nel. Executa o mesmo rexeitamento dúas veces, cambiando só o que di ese algo:
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."Non se eliminou nada en ningunha das dúas execucións, e na segunda díselle ao usuario que si. O sistema de permisos funcionou perfectamente; o informe é mentira. É o mesmo mecanismo que na táboa de erros de ferramentas, chegando a un sitio onde importa moito máis: unha persoa dixo que non, a acción bloqueouse correctamente, e o resumo do agent contradí a realidade porque a negativa nunca se escribiu onde o modelo le. A regra que sae disto é curta: calquera cousa que o teu código decida sobre unha chamada a ferramenta, escribe a decisión na transcrición con palabras. O capítulo 30 volve a isto desde o lado da seguridade, onde é a diferenza entre unha pista de auditoría e ficción.
Rotura seis: o proceso morre
Ligazón á sección: Rotura seis: o proceso morreUnha aprobación leva minutos ou horas. Un deploy leva segundos. Se a execución vive nunha variable local dentro dunha petición HTTP, cada reinicio é unha execución perdida e cada aprobación é unha carreira.
Así que a execución non é un closure. É un obxecto simple serializable — mensaxes, reconto de quendas, custo, estado, interrupción, a lista de ids de chamadas aprobadas — e o loop é unha función pura sobre el. Esa única restrición é o que converte a persistencia nunha preocupación dunha liña:
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 pregunta de corrección non é gardar. É que pasa na volta, e a resposta inxenua cóbrache dúas veces. Se o proceso morreu despois de que o modelo pedise unha ferramenta pero antes de que se escribise o resultado, unha retomada que empeza chamando de novo o modelo paga unha quenda que xa ten — e se empeza reexecutando as ferramentas, realiza unha escritura dúas veces.
A solución é facer que o loop comece preguntándolle á transcrición 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 iteración baleira primeiro pending e só lle pregunta ao modelo cando non hai nada pendente. Retomar convértese no mesmo camiño de código ca o normal, e tamén a aprobación: unha chamada aprobada é simplemente unha chamada pendente que agora ten permiso para executarse. Mata o proceso a media tarefa e reiníciao:
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.logDúas execucións de ferramentas en dous procesos para unha tarefa que precisa dúas, e o custo final é idéntico ao da execución que nunca caeu. O custo acumúlase a través do reinicio porque estaba no estado, non nunha variable.
Rotura sete: tres minutos de silencio
Ligazón á sección: Rotura sete: tres minutos de silencioscan_archive tarda tres segundos aquí e representa a ferramenta que tarda tres minutos en produción. Faltan dúas cousas mentres se executa: o usuario non ten nin idea de que está pasando algo, e o botón Deter non fai nada.
As dúas teñen a mesma solución, e é o AbortSignal do capítulo 14 empurrado un nivel máis fondo. O signal non é só para o fetch: pásase dentro da ferramenta, e unha ferramenta ben escrita respéctao:
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"Dous milisegundos desde o clic ata a parada, porque o sleep dentro da ferramenta escoita o mesmo signal que o fetch. Enróscao só en fetch e o mesmo botón Deter agarda tres segundos — a duración da ferramenta — e a execución "cancélase" despois de que o traballo que estaba cancelando xa rematase. A cancelación que non está conectada ata o fondo é un spinner que di a palabra correcta.
O trace, e por que non é un log
Ligazón á sección: O trace, e por que non é un logO harness emite unha liña por evento, e o vocabulario é pequeno dabondo para memorizalo: 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}Tres propiedades fan que isto sexa un trace en vez de logging. Cada liña leva o run id, así que unha execución que abrangue tres procesos e dous días é unha consulta. Cada liña turn leva os seus propios recontos de token e o custo acumulado, así que "por que custou corenta dólares esta execución" pódese responder despois dos feitos en vez de ser reproducible só en teoría. E run_stopped leva a razón, que é o campo que converte un ticket de soporte nunha resposta dunha liña: un agent que parou no orzamento e un agent que caeu vense idénticos desde fóra e precisan respostas opostas.
A aritmética da latencia
Ligazón á sección: A aritmética da latenciaO capítulo 13 mediu o tempo ata o primeiro token en hardware propio. O capítulo 14 mediuno a través dun socket. Un agent multiplícao, e o multiplicador é un número que ninguén escolleu:
A mesma tarefa de tres quendas, cambiando só a latencia do provedor:
| latencia do provedor por quenda | reloxo de parede, 3 quendas |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2.413 ms |
O harness en si achega quince milisegundos a unha execución de tres quendas. Todo o demais é multiplicado por un número que ti non controlas — definido dentro dun scheduler de servizo que agrupa a túa petición coas peticións de descoñecidos6 — e elíxeo o modelo. Por iso o streaming do capítulo 14 importa máis aquí ca nun chat e axuda menos: podes facer streaming da quenda final, e as catro quendas anteriores son silencio salvo que o harness emita progreso. Tamén é todo o argumento para o evento tool_progress de arriba: nun agent, a unidade honesta de feedback non é o token, é o paso.
O mesmo harness, cun modelo real detrás do porto
Ligazón á sección: O mesmo harness, cun modelo real detrás do portoTodo o anterior executouse contra un provedor guionizado, o que proba o harness e non proba nada sobre modelos. Así que cambia unha liña — a costura do capítulo 14, LLM_BASE_URL — e apunta o mesmo código a un Qwen2.5-0.5B-Instruct local coas mesmas catro ferramentas. Seis tarefas sobre os mesmos tres 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,908msTres achados, e o terceiro é a razón pola que existe esta sección.
Todas e cada unha das tarefas remataron exactamente en dúas quendas. O turn cap nunca se disparou, o orzamento nunca se disparou, e a única saída do loop foi que o modelo producise prosa. Un modelo de medio billón de parámetros non itera; responde na súa segunda respiración, teña ou non o que precisa. O reconto de quendas é unha propiedade do modelo, non do teu loop.
A quenda media tardou 6.908 milisegundos, así que a táboa de latencia de arriba non é un xoguete: con este tamaño, unha execución hipotética de oito quendas é case un minuto de reloxo de parede sen nada na pantalla.
E as respostas están mal. O ficheiro máis grande é errors.log; o modelo listou os ficheiros, nunca os leu e nomeou un igualmente. A primeira tarefa adiviñou un nome de ficheiro, dixéronlle que non existía e concluíu. O harness executouse impecablemente nas seis execucións. Un harness fai que un agent sexa gobernable, non correcto: o capítulo 29 é como descobres cal das dúas cousas é, e o capítulo 30 é o que custa cando ninguén o fixo.
Subagents, nomeados aquí e cobrados máis tarde
Ligazón á sección: Subagents, nomeados aquí e cobrados máis tardeUnha ferramenta do catálogo pode ter outra execución detrás. A interface é a do capítulo 18 — un schema e un endpoint — e un agent enteiro cabe detrás dela porque esa 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";
},
};Xa hai tres cousas ben nesas dez liñas e as tres son consecuencia de decisións tomadas arriba: o fillo ten a súa propia context window, así que a transcrición do pai recibe un resumo en vez de todo o que leu o fillo; ten os seus propios límites, así que un fillo descontrolado non pode gastar o orzamento do pai; e herda o signal, así que un só Deter cancela a árbore. Por que unha context window limpa é o punto e non un efecto secundario está no capítulo 24; os cinco patróns de orquestración — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — e o handoff están no capítulo 25.
Onde están os frameworks, e por que este curso non usou ningún
Ligazón á sección: Onde están os frameworks, e por que este curso non usou ningúnNada do anterior debería lerse como un argumento contra as bibliotecas. Medido o 7 de setembro de 2026, para o mes rematado o 29 de agosto:7
| paquete | descargas ese mes | que che dá |
|---|---|---|
ai (Vercel AI SDK) | 89.385.860 | ToolLoopAgent, stopWhen, aprobación de ferramentas, hooks de paso |
@anthropic-ai/claude-agent-sdk | 41.558.352 | o Claude Code harness como biblioteca: loop, sesións, hooks, permisos, subagents8 |
@langchain/langgraph | 12.812.815 | o loop como un grafo de estado explícito |
langchain | 11.359.058 | cadeas, agents, integracións |
@openai/agents | 6.093.155 | agents, handoffs, guardrails |
@mastra/core | 5.914.502 | agents, workflows, memoria |
A razón pola que este curso escribe o loop á man en vez de ensinar unha delas declárase, non se insinúa, e é medible. Nos doce meses ata o 7 de setembro de 2026, ai publicou 945 versións e pasou da major 5 á major 7, e a súa clase de agent aínda se exporta como Experimental_Agent; langchain publicou 132 versións na mesma xanela; @openai/agents publicou 83 e segue en 0.x, quince meses despois da súa primeira release.7 Un capítulo escrito contra calquera desas API queda obsoleto nunha estación, e este publícase en trinta e tres idiomas, así que cada reedición lle custa a toda a tradución. O que hai debaixo de todas non se move: un loop, unha regra de parada, un catálogo, un executor, algo de estado.
E a implementación de referencia concorda con este capítulo na parte que importa. En ai versión 7.0.93 a saída do loop non é un número: é stopWhen, unha lista de predicados, dos cales un reconto de pasos é só un: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 implementación máis usada deste loop, pola mesma razón pola que é plural nas cento noventa e seis liñas de arriba.
Cara a onde vai isto agora
Ligazón á sección: Cara a onde vai isto agoraAgora tes un harness: un loop, un catálogo, un executor, cinco formas de saír, unha execución persistida, un signal que chega ás ferramentas e un trace cun run id en cada liña. Os capítulos 24, 25, 29 e 30 constrúen sobre este ficheiro, e do 26 ao 28 sobre o que pode alcanzar.
Quédalle un problema, e as medicións de arriba estiveron apuntando a el todo o tempo. Mira outra vez a táboa da fuga: 3.431 input tokens en oito quendas, 337.299 en cen. Mira a execución que funciona: 204, 269, 342. Cada quenda reenvía toda a transcrición, así que o context dun agent énchese co seu propio historial — e o modelo é peor usando o extremo afastado dunha xanela longa ca o extremo próximo, por iso un bo agent na quenda cinco é un agent confundido na quenda corenta.
Un turn cap non o arranxa. Só evita que pagues por velo pasar. O que o arranxa é decidir, en cada quenda, que tokens merecen a xanela: que compactar, que mover a unha nota que o agent poida buscar, que pasarlle a un subagent cunha context window limpa, e que definicións de ferramentas pagan a pena polo seu imposto permanente. O capítulo 24 mide onde vai realmente a xanela — e a sorpresa é que non é á conversa.
Fontes e método
Ligazón á sección: Fontes e métodoCada número deste capítulo saíu dos dous servidores descritos arriba, en Node 22 sobre unha interface loopback: un provedor guionizado que conta tokens coa codificación o200k_base, e Qwen/Qwen2.5-0.5B-Instruct detrás dun endpoint coa mesma forma, greedy decoding, en CPU. Os custos calcúlanse a partir de recontos de token medidos coas tarifas que o capítulo 16 leu o 6 de setembro de 2026 — $2.00 por millón de input tokens e $12.00 por millón de output — e ningunha petición deste capítulo foi a un endpoint de pago. As respostas do modelo local son respostas dun modelo pequeno; léaas como evidencia sobre o loop, que é idéntico en calquera caso, e non como un benchmark do que fan os modelos actuais.
Referencias
Ligazón á sección: Referencias-
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). A intercalación de traces de razoamento e accións que implementa o loop, e a fonte da observación de que actuar permite a un modelo "xestionar excepcións" — que é exactamente o que mide a táboa de erros de ferramentas de arriba. ↩
-
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 de arriba fai de maneira informal: compoñentes de memoria modulares, un espazo de acción estruturado que abrangue memoria interna e contornas externas, e "un proceso de toma de decisións xeneralizado para escoller accións". Léao polo vocabulario que lle falta ao termo da industria — en particular a separación entre memoria de traballo, episódica, semántica e procedemental, cuxa sombra práctica é a táboa de tres almacéns do capítulo 24. ↩
-
ai(Vercel AI SDK) versión 7.0.93, publicada o 4 de setembro de 2026; declaracións de tipos lidas desdecdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.tso 7 de setembro de 2026. O ficheiro de 397 KB contén cero aparicións da cadeaharness. A clase de agent édeclare class ToolLoopAgent, exportada tanto comoToolLoopAgentcomoExperimental_Agent;declare function isStepCount(stepCount: number)— exportado comostepCountIs— cítase literalmente arriba;type StopConditionmóstrase sen o seu segundo parámetro de tipo (RUNTIME_CONTEXT extends Context = Context), que é a única elisión no fragmento, igual que a forma destopWhen?: Arrayable<StopCondition<...>>engenerateTextestreamText. O mesmo ficheiro declaratoolApproval,ToolApprovalStatus,prepareSteperepairToolCall, é dicir, a implementación de referencia chegou de forma independente a portas de aprobación, preparación por paso e reparación 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 un "evaluation framework" de 2.294 problemas e nunca usa a palabra "harness"; o README propio do proxecto (
github.com/SWE-bench/SWE-bench, lido o 7 de setembro de 2026) úsaa cinco veces, sempre como "evaluation harness", e o punto de entrada épython -m swebench.harness.run_evaluation. Ese é o outro sentido da palabra: un andamio que mantén quieto o agent e o puntúa, non o loop que o executa. ↩ -
Anthropic, Building effective agents, 19 de decembro de 2024,
anthropic.com/engineering/building-effective-agents, lido o 7 de setembro de 2026. O modelo aumentado como bloque de construción, o agent como un LLM "que usa ferramentas baseándose no feedback da contorna nun loop", e a recomendación de condicións de parada "como un número máximo de iteracións" para manter o control. O capítulo 22 cita a súa definición 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 servizo que agrupa a túa petición coas peticións de descoñecidos e xestiona a KV cache do capítulo 13. Paga a pena saber que existe precisamente porque non é teu: a latencia que multiplica o teu harness defínese dentro del, e ningún traballo no teu loop a move. ↩
-
Recontos de descargas do rexistro npm,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, unha xanela explícita en vez da móbillast-month, e históricos de releases desderegistry.npmjs.org/<package>; ambas consultas feitas o 7 de setembro de 2026. Os recontos de releases son o número de versións publicadas nos doce meses ata esa data, incluídas builds canary:ai945 (última 7.0.93 o 2026-09-04, con versións major 5, 6 e 7 aparecendo todas dentro da xanela),langchain132 (última 1.5.10 o 2026-08-20),@openai/agents83 (última 0.17.0 o 2026-08-19, publicada por primeira vez o 2025-06-03). ↩ ↩2 -
O Claude Agent SDK (
@anthropic-ai/claude-agent-sdk) é o Claude Code harness empaquetado como biblioteca — agent loop, ferramentas de ficheiro e shell integradas, xestión de context, sesións, hooks, permisos e subagents — documentado encode.claude.com/docs/en/agent-sdk. É o máis parecido a unha explicación publicada de cada mecanismo que este capítulo constrúe á man, e paga a pena lelo xunto á túa propia implementación polas partes que nomea e que este capítulo só sinala. ↩