Construeix un agent harness: el bucle i les cinc maneres de sortir-ne
Un bucle de quinze línies que funciona a la primera, trencat set vegades expressament, començant amb una fuga 77 cops més cara.
En aquesta pàgina
Comença per la part honesta, perquè ningú més ho dirà: "harness" és argot, no un estàndard. No hi ha cap especificació, cap comitè, cap definició de referència. Els quatre articles que cita aquest capítol — ReAct,1 CoALA,2 SWE-bench i vLLM — no fan servir ni una sola vegada aquesta paraula als seus resums. La implementació més descarregada d’això, el paquet ai de Vercel, amb 89,4 milions de descàrregues al mes, tampoc no la fa servir: la cadena harness apareix zero vegades als 397 KB de declaracions de tipus distribuïdes amb la versió 7.0.93.3 L’únic lloc on la paraula sí que té pes significa una cosa completament diferent. SWE-bench diu "harness" cinc vegades al seu README, sempre com a evaluation harness — l’esquelet contenidoritzat que aplica un pedaç i executa les proves — i el seu mòdul de Python és literalment swebench.harness.run_evaluation.4
Així doncs, dues coses diferents comparteixen nom. Un evaluation harness immobilitza l’agent i el puntua. Un agent harness és el programa que executa l’agent: crida el model, executa el que el model demana, decideix quan s’ha d’aturar i manté l’estat entremig. Aquest capítol en construeix el segon, en menys de dues-centes línies de TypeScript, sense cap framework.
El bucle en si té quinze línies i funciona al primer intent. Tot el que ve després és una manera de sortir-ne.
Mostra els detalls
Què necessita aquest capítol dels anteriors.
- Capítol 14 per al client: terminis, triatge d’estats, cancel·lació, claus d’idempotència i la tècnica del proveïdor simulat que es torna a fer servir aquí.
- Capítol 16 per a l’aritmètica: els token d’entrada creixen amb el quadrat de la conversa, i les tarifes utilitzades més avall són les que s’hi van llegir el 6 de setembre de 2026.
- Capítol 18 per al catàleg d’eines: un esquema que el model veu, un endpoint que no veu mai i la regla que els errors són context, no excepcions.
- Capítol 22 pel bucle que aquest hereta i per les dues definicions publicades d’"agent" que no estan d’acord entre elles.
Aquí no hi ha tensors. Aquest és el segon node de dependències del curs: els capítols 24, 25, 29 i 30 s’executen sobre el fitxer de sota, i del 26 al 28 es construeixen sobre allò que pot abastar.
Un proveïdor que pots guionitzar
Enllaç a la secció: Un proveïdor que pots guionitzarEl capítol 14 no es podia escriure contra un proveïdor real, perquè no pots demanar-li un 429 en un moment escollit. Aquest capítol té el mateix problema amb una altra forma: no pots demanar a un model real que s’embali, o que sol·liciti la mateixa eina dues vegades seguides, a demanda i de manera reproduïble.
Així que el primer programa és un proveïdor guionitzat: un endpoint amb la forma d’una API de complecions de xat la resposta del qual és una funció de l’índex del torn i del que les eines han retornat fins ara. Compta token amb un codificador de parells de bytes real, de manera que els diners de més avall són aritmètica i no decoració.
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);Dues línies sostenen el disseny. L’índex de torn es deriva de la conversa, no es guarda en una variable, així que el proveïdor no té estat i una execució es pot matar i reprendre contra ell. I recover llegeix els resultats de les eines abans de decidir: un model guionitzat que llegeix la seva pròpia transcripció és el mínim necessari per mesurar si el harness li ha donat alguna cosa que valgui la pena llegir.
El catàleg és el del capítol 18, quatre eines sobre tres fitxers: list_files, read_file, delete_file — marcada com a needsApproval — i scan_archive, que és lenta expressament.
El bucle que funciona
Enllaç a la secció: El bucle que funcionaAquí tens tota la idea, abans de qualsevol de les parts que la fan suportable.
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 });
}
}Apunta’l al proveïdor guionitzat i fa exactament el que sembla que fa:
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 torns, dues execucions d’eines, un quart de cèntim de dòlar. Fixa’t en l’última línia: 204, 269, 342. Cada torn reenvia tot el que tenia abans, que és la factura quadràtica del capítol 16 arribant a un lloc on ningú no ha escrit res. La resta d’aquest capítol és el que passa quan aquesta línia no deixa de créixer.
Trencament u: la tasca que no s’acaba mai
Enllaç a la secció: Trencament u: la tasca que no s’acaba maiApunta el mateix bucle al script runaway — un model que demana una eina a cada torn i no emet mai prosa — i el return marcat no s’activa mai. No hi ha cap altra sortida. El programa s’executa fins que mor el procés o la targeta de crèdit.
La solució és una línia, és el primer control que recomana la literatura,5 i tothom l’acaba escrivint. El que gairebé ningú no fa és mesurar què val:
| límit de torns | crides al model | token d’entrada | cost |
|---|---|---|---|
| 8 | 8 | 3.431 | $0,009070 |
| 20 | 20 | 16.259 | $0,038038 |
| 50 | 50 | 88.649 | $0,191098 |
| 100 | 100 | 337.299 | $0,702198 |
Llegeix les dues últimes files juntes. Doblar el límit de 50 a 100 no va doblar el cost; el va multiplicar per 3,7. Els token d’entrada van passar de 88.649 a 337.299, un factor de 3,8, perquè el torn porta tots els torns anteriors i el total és . Un límit de torns no és un dial lineal. És un dial sobre l’arrel quadrada del teu pitjor cas, i per això pujar-lo de 20 a 100 "per anar sobre segur" és una decisió que val la pena posar preu abans de prendre-la.
Trencament dos: un límit de torns no és un límit de diners
Enllaç a la secció: Trencament dos: un límit de torns no és un límit de dinersEl problema d’un límit de torns és que un torn no té un preu fix. Vint torns sobre una transcripció curta costen $0,038 a dalt. Vint torns amb un catàleg de 200 eines, un conjunt de documents recuperats i quaranta missatges d’historial costen centenars de vegades més, i el límit no ho sap. El que l’operador vol limitar és la factura.
Així que el bucle compta diners, fent servir el computeCost del capítol 16 contra les tarifes que s’hi van llegir — $2,00 per milió de token d’entrada i $12,00 per milió de sortida, per al model que es tarifa al llarg de tot aquest curs:
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);El mateix script desbocat, sense cap límit de torns, tres pressupostos:
| pressupost | torns assolits | despesa real |
|---|---|---|
| $0,01 | 9 | $0,010780 |
| $0,05 | 24 | $0,051790 |
| $0,20 | 52 | $0,205398 |
Val la pena posar nom a dues coses. Primer, el pressupost compra un nombre diferent de torns cada vegada, i aquesta és la idea: limita allò que importa a l’operador i deixa que el recompte de torns caigui on el posi la transcripció. Segon, totes les files se’n passen. El pressupost era de $0,010 i es van gastar $0,010780, perquè la comprovació s’executa abans d’un torn i el preu d’un torn no se sap fins que s’ha acabat. No pots limitar la despesa exactament; pots limitar-la dins del cost d’un torn. Digues-ho a la interfície en lloc de fingir, i posa la comprovació abans de la crida perquè l’excés sigui d’un torn i no de dos.
Cinc maneres de sortir del bucle, no una
Enllaç a la secció: Cinc maneres de sortir del bucle, no unaA aquestes altures el bucle té tres sortides, i la forma de la resta del capítol ja es veu. Una execució de producció acaba exactament d’una de cinc maneres, i no són variacions l’una de l’altra:
| com acaba | qui ho ha decidit | què ha de fer qui crida |
|---|---|---|
| el model ha deixat de demanar | el model | llegir la resposta |
| límit de torns | tu, per avançat | pujar el límit o acceptar un resultat parcial |
| pressupost esgotat | tu, per avançat | aprovar més diners o acceptar un resultat parcial |
| un error que no pots reintentar | el proveïdor o una eina | arreglar el deploy; el triatge del capítol 14 decideix |
| ha intervingut una persona | una persona | esperar un veredicte i reprendre |
Col·lapsar tot això en un sol booleà és l’error de disseny més habitual en aquest fitxer, i és car d’una manera concreta: tres de les cinc són reprenibles i dues no. Un agent que ha arribat al seu límit de torns té una transcripció vàlida, un resultat parcial real i un pas següent; un agent que ha rebut un 401 no té res d’això. Per tant, el harness registra el motiu com a dades:
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 };Trencament tres: una eina falla
Enllaç a la secció: Trencament tres: una eina fallaEl capítol 18 acabava amb una afirmació sense número: retorna l’error d’una eina al model com a resultat d’eina en lloc de llançar-lo, i el model normalment s’arregla sol. Aquí tens el número.
Un error, tres polítiques. El model guionitzat endevina un fitxer que no existeix; l’eina llança no such file: timeout.log. Call list_files to see what exists.
| què fa el harness amb l’error | torns | execucions d’eina | cost | què obté l’usuari |
|---|---|---|---|---|
| el llança fora del bucle | 1 | 1 | $0,000756 | una traça de pila |
retorna Error: the tool failed. | 2 | 1 | $0,001462 | "No he pogut llegir el fitxer, així que no ho sé." |
| retorna què ha passat realment | 4 | 3 | $0,003550 | "errors.log menciona un timeout." |
La tercera fila costa 4,7 vegades la primera i és l’única que respon la pregunta. I la segona fila és la interessant, perquè és el que fan realment la majoria de bases de codi: l’error s’ha capturat, el bucle ha sobreviscut, al model se li ha dit que alguna cosa ha fallat i no què, i s’ha rendit educadament. La diferència entre les files dos i tres no és gestió d’errors. És una frase escrita per a un lector.
Per tant, el harness tracta una eina que llança com a dades i converteix la redacció en una 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);
}El capítol 18 també avisava de l’altra banda, i aquesta també té un preu. Apunta el bucle a una eina que falla per un motiu que cap missatge pot arreglar — una lectura que el procés no té permís per fer — i el model la reintenta per 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 execucions idèntiques d’una crida que no pot tenir èxit, 5,2 vegades el cost de l’execució que es va recuperar d’un error arreglable, i res al final. Els errors són context; un error permanent és context que enverina la resta de l’execució. La distinció és el triatge d’estats del capítol 14 pujat un nivell: un error sobre el qual el model pot actuar torna a la transcripció, i un error sobre el qual no pot actuar hauria d’aturar l’execució amb un motiu. Avui, el límit de torns és el que s’interposa entre tu i el segon cas; això és un mínim, no una solució.
Trencament quatre: la mateixa crida, dues vegades
Enllaç a la secció: Trencament quatre: la mateixa crida, dues vegadesAra ve l’error que la majoria de gent assumeix que no pot passar. Els models es repeteixen. Demana a qualsevol bucle que s’executi prou temps i veuràs la mateixa eina amb els mateixos arguments en dos torns consecutius.
Mesurat contra la línia base de la mateixa tasca sense la repetició:
| torns | execucions d’eina | cost | |
|---|---|---|---|
| la tasca, sense repetició | 2 | 1 | $0,001396 |
| la mateixa tasca, una crida repetida | 3 | 2 | $0,002446 |
| repetida, amb una memòria cau de resultats en eines de només lectura | 3 | 1 | $0,002446 |
La crida duplicada va costar $0,001050 extra, un augment del 75 %, i aquí hi ha la part que sorprèn la gent: posar el resultat a la memòria cau no en va recuperar res. La deduplicació va estalviar l’execució de l’eina, no el torn, perquè quan el teu codi detecta la repetició el model ja ha cobrat per demanar-la. L’estalvi és real quan l’eina és lenta, té límits de quota o es factura per crida — i és zero a la partida que va créixer.
Hi ha una versió pitjor. Aplica la mateixa memòria cau a una eina que escriu, i la segona crida silenciosament no passa:
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"]Quina d’aquestes és correcta? Cap, de manera demostrable. El protocol diu que són dues crides: porten dos valors tool_call_id diferents. Els arguments diuen que podrien ser una. Un harness que decideix comparant cadenes d’arguments algun dia s’empassarà la segona de dues càrregues idèntiques i intencionades — i el capítol 14 ja va posar nom a l’únic mecanisme que resol això honestament, que és una clau d’idempotència generada per operació lògica per la capa que sap què és l’operació. Fins que l’eina no en porti una, el valor per defecte defensable és la porta de només lectura de dalt: posa a la memòria cau les lectures, executa les escriptures i deixa que la idempotència pròpia de l’escriptura s’ocupi de la resta.
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;
}Trencament cinc: esborra alguna cosa
Enllaç a la secció: Trencament cinc: esborra alguna cosaEl script destructive llista els fitxers i després demana esborrar-ne un que la tasca no havia mencionat mai. Res del bucle fins ara no ho aturaria.
Una eina marcada com a needsApproval no falla i no continua. Atura l’execució i retorna el control, amb tot el que una persona necessita per 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."Aquest és tot el mecanisme, i la raó per la qual és un return i no un callback és la secció següent: entre l’aturada i el veredicte, el procés pot haver deixat d’existir.
Però primer, la mesura que ningú no espera. Un rebuig no és l’absència d’un resultat: la transcripció té una ranura indexada per tool_call_id i alguna cosa hi ha d’anar. Executa el mateix rebuig dues vegades, canviant només què diu aquesta cosa:
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."No es va esborrar res en cap de les dues execucions, i en la segona s’informa a l’usuari que sí. El sistema de permisos va funcionar perfectament; l’informe és una mentida. És el mateix mecanisme que a la taula d’errors d’eina, arribant a un lloc on importa molt més: una persona va dir que no, l’acció es va bloquejar correctament i el resum de l’agent contradiu la realitat perquè la negativa no es va escriure mai allà on el model llegeix. La regla que se’n deriva és curta: sigui el que sigui que el teu codi decideixi sobre una crida d’eina, escriu la decisió a la transcripció amb paraules. El capítol 30 hi torna des del costat de la seguretat, on és la diferència entre una pista d’auditoria i ficció.
Trencament sis: el procés mor
Enllaç a la secció: Trencament sis: el procés morUna aprovació triga minuts o hores. Un deploy triga segons. Si l’execució viu en una variable local dins d’una petició HTTP, cada reinici és una execució perduda i cada aprovació és una cursa.
Així que l’execució no és una clausura. És un objecte pla serialitzable — missatges, recompte de torns, cost, estat, interrupció, la llista d’identificadors de crides aprovades — i el bucle és una funció pura sobre ell. Aquesta única restricció és el que fa que la persistència sigui una qüestió d’una línia:
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"));La pregunta de correcció no és desar. És què passa quan hi tornes a entrar, i la resposta ingènua et cobra dues vegades. Si el procés ha mort després que el model demanés una eina però abans que se n’escrivís el resultat, una represa que comença cridant el model de nou paga un torn que ja té — i si comença reexecutant les eines, fa una escriptura dues vegades.
La solució és fer que el bucle comenci preguntant a la transcripció què queda pendent:
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ó buida primer pending i només pregunta al model quan no hi ha res pendent. Reprendre esdevé el mateix camí de codi que l’execució normal, i l’aprovació també: una crida aprovada és simplement una crida pendent que ara pot executar-se. Mata el procés a mitja tasca i reinicia’l:
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.logDues execucions d’eina en dos processos per a una tasca que en necessita dues, i el cost final és idèntic al de l’execució que no va fallar mai. El cost s’acumula a través del reinici perquè era a l’estat, no en una variable.
Trencament set: tres minuts de silenci
Enllaç a la secció: Trencament set: tres minuts de silenciscan_archive triga tres segons aquí i representa l’eina que triga tres minuts a producció. Mentre s’executa falten dues coses: l’usuari no té ni idea que estigui passant res, i el botó Atura no fa res.
Totes dues tenen la mateixa solució, i és el AbortSignal del capítol 14 empès un nivell més avall. El senyal no és només per al fetch: es passa dins de l’eina, i una eina ben escrita el respecta:
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"Dos mil·lisegons des del clic fins a l’aturada, perquè l’espera dins de l’eina escolta el mateix senyal que el fetch. Fes-lo arribar només a fetch i el mateix botó Atura espera tres segons — la durada de l’eina — i l’execució es "cancel·la" després que la feina que estava cancel·lant ja hagi acabat. Una cancel·lació que no està connectada fins al fons és un indicador de càrrega que diu la paraula correcta.
La traça, i per què no és un log
Enllaç a la secció: La traça, i per què no és un logEl harness emet una línia per esdeveniment, i el vocabulari és prou petit per memoritzar-lo: 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 propietats fan que això sigui una traça i no logging. Cada línia porta el run id, així que una execució que abasta tres processos i dos dies és una sola consulta. Cada línia turn porta els seus propis recomptes de token i el cost acumulat, així que "per què aquesta execució va costar quaranta dòlars" es pot respondre a posteriori en lloc de ser reproduïble només en teoria. I run_stopped porta el motiu, que és el camp que converteix un tiquet de suport en una resposta d’una línia: un agent que s’ha aturat pel pressupost i un agent que ha petat semblen idèntics des de fora i necessiten respostes oposades.
L’aritmètica de la latència
Enllaç a la secció: L’aritmètica de la latènciaEl capítol 13 va mesurar el temps fins al primer token en maquinari propi. El capítol 14 el va mesurar a través d’un socket. Un agent el multiplica, i el multiplicador és un nombre que ningú no ha triat:
La mateixa tasca de tres torns, canviant només la latència del proveïdor:
| latència del proveïdor per torn | temps real, 3 torns |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2.413 ms |
El harness en si aporta quinze mil·lisegons a una execució de tres torns. Tota la resta és multiplicat per un nombre que no controles — definit dins d’un planificador de servei que agrupa la teva petició amb les peticions de desconeguts6 — i el tria el model. Per això l’streaming del capítol 14 importa més aquí que en un xat i ajuda menys: pots fer streaming del torn final, i els quatre torns anteriors són silenci tret que el harness emeti progrés. També és tot l’argument per a l’esdeveniment tool_progress de dalt: en un agent, la unitat honesta de feedback no és el token, és el pas.
El mateix harness, un model real darrere del port
Enllaç a la secció: El mateix harness, un model real darrere del portTot el que hi ha a dalt s’ha executat contra un proveïdor guionitzat, cosa que demostra el harness i no demostra res sobre els models. Així que canvia una línia — la costura del capítol 14, LLM_BASE_URL — i apunta el mateix codi a un Qwen2.5-0.5B-Instruct local amb les mateixes quatre eines. Sis tasques sobre els mateixos tres fitxers:
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 conclusions, i la tercera és el motiu pel qual existeix aquesta secció.
Totes i cadascuna de les tasques van acabar exactament en dos torns. El límit de torns no es va activar mai, el pressupost no es va activar mai, i l’única sortida del bucle va ser que el model produís prosa. Un model de mig bilió de paràmetres no itera; respon al segon alè tant si té el que necessita com si no. El recompte de torns és una propietat del model, no del teu bucle.
El torn mitjà va trigar 6.908 mil·lisegons, així que la taula de latència de dalt no és cap joguina: a aquesta mida, una execució hipotètica de vuit torns és gairebé un minut de temps real sense res a la pantalla.
I les respostes són errònies. El fitxer més gran és errors.log; el model va llistar els fitxers, no els va llegir mai i en va anomenar un igualment. La primera tasca va endevinar un nom de fitxer, se li va dir que no existia i va concloure. El harness es va executar impecablement en les sis execucions. Un harness fa que un agent sigui governable, no correcte: el capítol 29 és com esbrines quina de les dues coses passa, i el capítol 30 és què costa quan ningú no ho ha fet.
Subagents, anomenats aquí i cobrats més endavant
Enllaç a la secció: Subagents, anomenats aquí i cobrats més endavantUna eina del catàleg pot tenir una altra execució al darrere. La interfície és la del capítol 18 — un esquema i un endpoint — i un agent sencer hi cap al darrere perquè aquesta interfície és estreta:
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";
},
};Tres coses ja són correctes en aquestes deu línies, i totes tres són conseqüència de decisions preses més amunt: el fill té la seva pròpia finestra, així que la transcripció del pare rep un resum en lloc de tot el que el fill ha llegit; té els seus propis límits, així que un fill desbocat no pot gastar el pressupost del pare; i hereta el senyal, així que un sol Atura cancel·la l’arbre. Per què una finestra neta és el punt i no un efecte secundari és el capítol 24; els cinc patrons d’orquestració — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — i el handoff són el capítol 25.
On són els frameworks, i per què aquest curs no n’ha fet servir cap
Enllaç a la secció: On són els frameworks, i per què aquest curs no n’ha fet servir capRes del que hi ha a dalt s’hauria de llegir com un argument contra les biblioteques. Mesurat el 7 de setembre de 2026, per al mes acabat el 29 d’agost:7
| paquet | descàrregues aquell mes | què et dona |
|---|---|---|
ai (Vercel AI SDK) | 89.385.860 | ToolLoopAgent, stopWhen, aprovació d’eines, hooks de pas |
@anthropic-ai/claude-agent-sdk | 41.558.352 | el Claude Code harness com a biblioteca: bucle, sessions, hooks, permisos, subagents8 |
@langchain/langgraph | 12.812.815 | el bucle com un graf d’estat explícit |
langchain | 11.359.058 | cadenes, agents, integracions |
@openai/agents | 6.093.155 | agents, handoffs, guardrails |
@mastra/core | 5.914.502 | agents, workflows, memòria |
La raó per la qual aquest curs escriu el bucle a mà en lloc d’ensenyar-ne un es declara en comptes d’insinuar-se, i és mesurable. En els dotze mesos fins al 7 de setembre de 2026, ai va publicar 945 versions i va passar de la major 5 a la major 7, i la seva classe agent encara s’exporta com a Experimental_Agent; langchain va publicar 132 versions en la mateixa finestra; @openai/agents en va publicar 83 i encara és a 0.x, quinze mesos després de la primera publicació.7 Un capítol escrit contra qualsevol d’aquestes API queda antiquat en una temporada, i aquest es publica en trenta-tres llengües, de manera que cada reedició costa tota la traducció. El que hi ha a sota de totes no es mou: un bucle, una regla d’aturada, un catàleg, un executor, una mica d’estat.
I la implementació de referència està d’acord amb aquest capítol sobre la part que importa. A la versió 7.0.93 de ai, la sortida del bucle no és un número: és stopWhen, una llista de predicats, dels quals un recompte de passos és nomé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 stepCountIsAturar-se és plural a la implementació més utilitzada d’aquest bucle, per la mateixa raó que és plural a les cent noranta-sis línies de dalt.
Cap a on va això ara
Enllaç a la secció: Cap a on va això araAra tens un harness: un bucle, un catàleg, un executor, cinc maneres de sortir, una execució persistida, un senyal que arriba a les eines i una traça amb un run id a cada línia. Els capítols 24, 25, 29 i 30 es construeixen sobre aquest fitxer, i del 26 al 28 sobre allò que pot abastar.
Li queda un problema, i les mesures de dalt l’han estat assenyalant tot el camí. Mira un cop més la taula del desbocament: 3.431 token d’entrada a vuit torns, 337.299 a cent. Mira l’execució que funciona: 204, 269, 342. Cada torn reenvia tota la transcripció, així que el context d’un agent s’omple amb la seva pròpia història — i el model és pitjor fent servir l’extrem llunyà d’una finestra llarga que l’extrem proper, per això un bon agent al torn cinc és un agent confós al torn quaranta.
Un límit de torns no ho arregla. Només evita que paguis per veure-ho passar. El que ho arregla és decidir, a cada torn, quins token mereixen la finestra: què compactar, què moure fora cap a una nota que l’agent pugui recuperar, què passar a un subagent amb una finestra neta, i quines definicions d’eina valen el seu impost permanent. El capítol 24 mesura on va realment la finestra — i la sorpresa és que no és a la conversa.
Fonts i mètode
Enllaç a la secció: Fonts i mètodeTots els números d’aquest capítol van sortir dels dos servidors descrits a dalt, amb Node 22 sobre una interfície loopback: un proveïdor guionitzat que compta token amb la codificació o200k_base, i Qwen/Qwen2.5-0.5B-Instruct darrere d’un endpoint de la mateixa forma, greedy decoding, sobre CPU. Els costos es calculen a partir dels recomptes de token mesurats a les tarifes que el capítol 16 va llegir el 6 de setembre de 2026 — $2,00 per milió de token d’entrada i $12,00 per milió de sortida — i cap petició d’aquest capítol va anar a un endpoint de pagament. Les respostes del model local són respostes d’un model petit; llegeix-les com a evidència sobre el bucle, que és idèntic en tots dos casos, i no com un benchmark del que fan els models actuals.
Referències
Enllaç a la secció: Referències-
Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. i Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). La intercalació de traces de raonament i accions que implementa el bucle, i la font de l’observació que actuar permet a un model "gestionar excepcions" — que és exactament el que mesura la taula d’errors d’eina de dalt. ↩
-
Sumers, T. R., Yao, S., Narasimhan, K. i Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). El tractament formal del que el bucle de dalt fa informalment: components de memòria modulars, un espai d’acció estructurat que abasta la memòria interna i els entorns externs, i "un procés generalitzat de presa de decisions per triar accions". Llegeix-lo pel vocabulari que li falta al terme de la indústria — en particular la separació entre memòria de treball, episòdica, semàntica i procedimental, l’ombra pràctica de la qual és la taula de tres magatzems del capítol 24. ↩
-
ai(Vercel AI SDK) versió 7.0.93, publicada el 4 de setembre de 2026; declaracions de tipus llegides decdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.tsel 7 de setembre de 2026. El fitxer de 397 KB conté zero aparicions de la cadenaharness. La classe agent ésdeclare class ToolLoopAgent, exportada tant com aToolLoopAgentcom com aExperimental_Agent;declare function isStepCount(stepCount: number)— exportat com astepCountIs— se cita literalment a dalt;type StopConditiones mostra sense el segon paràmetre de tipus (RUNTIME_CONTEXT extends Context = Context), que és l’única elisió del fragment, igual que la forma destopWhen?: Arrayable<StopCondition<...>>agenerateTextistreamText. El mateix fitxer declaratoolApproval,ToolApprovalStatus,prepareStepirepairToolCall, és a dir, la implementació de referència ha arribat independentment a portes d’aprovació, preparació per pas i reparació d’errors. ↩ ↩2 -
Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. i Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). El resum anomena l’artefacte "evaluation framework" de 2.294 problemes i no fa servir mai la paraula "harness"; el README del projecte mateix (
github.com/SWE-bench/SWE-bench, llegit el 7 de setembre de 2026) la fa servir cinc vegades, sempre com a "evaluation harness", i el punt d’entrada éspython -m swebench.harness.run_evaluation. Aquest és l’altre sentit de la paraula: un esquelet que immobilitza l’agent i el puntua, no el bucle que l’executa. ↩ -
Anthropic, Building effective agents, 19 de desembre de 2024,
anthropic.com/engineering/building-effective-agents, llegit el 7 de setembre de 2026. El model augmentat com a bloc de construcció, l’agent com un LLM "que usa eines basant-se en feedback ambiental en un bucle", i la recomanació de condicions d’aturada "com ara un nombre màxim d’iteracions" per mantenir el control. El capítol 22 en cita la definició sencera. ↩ -
Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. i Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). L’altre bucle: el planificador de servei que agrupa la teva petició amb les peticions de desconeguts i gestiona la KV cache del capítol 13. Val la pena saber que existeix precisament perquè no és teu: la latència que multiplica el teu harness es defineix dins seu, i cap quantitat de feina sobre el teu bucle la mou. ↩
-
Recompte de descàrregues del registre npm,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, una finestra explícita en lloc de la finestra mòbillast-month, i historials de versions deregistry.npmjs.org/<package>; tots dos consultats el 7 de setembre de 2026. Els recomptes de versions són el nombre de versions publicades en els dotze mesos fins a aquella data, inclosos els builds canary:ai945 (última 7.0.93 el 2026-09-04, amb les versions majors 5, 6 i 7 apareixent totes dins de la finestra),langchain132 (última 1.5.10 el 2026-08-20),@openai/agents83 (última 0.17.0 el 2026-08-19, primera publicada el 2025-06-03). ↩ ↩2 -
El Claude Agent SDK (
@anthropic-ai/claude-agent-sdk) és el Claude Code harness empaquetat com a biblioteca — bucle d’agent, eines integrades de fitxers i shell, gestió de context, sessions, hooks, permisos i subagents — documentat acode.claude.com/docs/en/agent-sdk. És el més semblant a un relat publicat de cada mecanisme que aquest capítol construeix a mà, i val la pena llegir-lo al costat de la teva pròpia implementació per les parts que anomena i que aquest capítol només apunta. ↩