Construiește un agent harness: bucla și cele cinci ieșiri
O buclă de 15 linii care merge din prima, apoi stricată intenționat de șapte ori — inclusiv o rulare scăpată de sub control de 77× mai scumpă.
Pe această pagină
Începe cu partea sinceră, pentru că nimeni altcineva nu o va spune: „harness” este jargon, nu un standard. Nu există specificație, comitet sau definiție de referință. Cele patru lucrări citate în acest capitol — ReAct,1 CoALA,2 SWE-bench și vLLM — nu folosesc cuvântul nici măcar o dată în rezumatele lor. Cea mai descărcată implementare a lucrului în sine, pachetul ai de la Vercel, cu 89,4 milioane de descărcări pe lună, nu îl folosește nici ea: șirul harness apare de zero ori în cei 397 KB de declarații de tip livrați de versiunea 7.0.93.3 Singurul loc în care cuvântul chiar duce greutatea înseamnă cu totul altceva. SWE-bench spune „harness” de cinci ori în README, mereu ca evaluation harness — schela containerizată care aplică un patch și rulează testele — iar modulul său Python este literalmente swebench.harness.run_evaluation.4
Așadar, două lucruri diferite împart același nume. Un evaluation harness ține agent pe loc și îl punctează. Un agent harness este programul care rulează agent: cheamă modelul, execută ce cere modelul, decide când să se oprească și păstrează starea între pași. Acest capitol îl construiește pe al doilea, în sub două sute de linii de TypeScript, fără niciun framework.
Bucla în sine are cincisprezece linii și funcționează din prima încercare. Tot ce urmează după aceea este o modalitate de a ieși din ea.
Afișează detaliile
De ce are nevoie acest capitol din cele anterioare.
- Capitolul 14 pentru client: termene-limită, trierea statusurilor, anulare, chei de idempotency și tehnica de provider mock folosită din nou aici.
- Capitolul 16 pentru aritmetică: input tokens cresc cu pătratul conversației, iar tarifele folosite mai jos sunt cele citite acolo pe 6 septembrie 2026.
- Capitolul 18 pentru catalogul de tools: o schemă pe care modelul o vede, un endpoint pe care nu îl vede niciodată și regula că erorile sunt context, nu excepții.
- Capitolul 22 pentru bucla pe care aceasta o moștenește și pentru cele două definiții publicate ale lui „agent” care nu sunt de acord una cu cealaltă.
Fără tensori aici. Acesta este al doilea hub de dependențe al cursului: Capitolele 24, 25, 29 și 30 rulează pe fișierul de mai jos, iar 26 până la 28 construiesc pe ce poate atinge el.
Un provider pe care îl poți script-a
Link către secțiunea: Un provider pe care îl poți script-aCapitolul 14 nu putea fi scris pe un provider real, pentru că nu poți cere unuia un 429 într-un moment ales. Acest capitol are aceeași problemă într-o altă formă: nu poți cere unui model real să o ia razna sau să solicite același tool de două ori la rând, la cerere și reproductibil.
Așa că primul program este un provider scriptat: un endpoint cu forma unui API de chat completions al cărui răspuns este o funcție de indexul turei și de ce au returnat tools până atunci. Numără tokens cu un encoder byte-pair real, așa că banii de mai jos sunt aritmetică, nu decor.
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);Două linii poartă designul. Indexul turei este derivat din conversație, nu ținut într-o variabilă, deci providerul este stateless și o rulare poate fi omorâtă și reluată împotriva lui. Iar recover citește rezultatele tools înainte să decidă: un model scriptat care își citește propria transcriere este minimul necesar pentru a măsura dacă harness i-a dat ceva ce merită citit.
Catalogul este cel din Capitolul 18, patru tools peste trei fișiere: list_files, read_file, delete_file — marcat needsApproval — și scan_archive, care este lent intenționat.
Bucla care funcționează
Link către secțiunea: Bucla care funcționeazăIată întreaga idee, înainte de oricare dintre piesele care o fac supraviețuibilă.
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 });
}
}Îndreapt-o către providerul scriptat și face exact ce pare că face:
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, 342Trei ture, două execuții de tool, un sfert de cent american. Observă ultima linie: 204, 269, 342. Fiecare tură retrimite tot ce a fost înaintea ei, ceea ce este factura quadratică din Capitolul 16 ajungând într-un loc unde nu a tastat nimeni nimic. Restul acestui capitol este ce se întâmplă când linia aceea nu se oprește din crescut.
Stricarea unu: taskul care nu se termină niciodată
Link către secțiunea: Stricarea unu: taskul care nu se termină niciodatăÎndreaptă aceeași buclă către scriptul runaway — un model care cere un tool la fiecare tură și nu emite niciodată proză — iar return marcat nu se declanșează niciodată. Nu există altă ieșire. Programul rulează până moare procesul sau cardul de credit.
Remedierea este o singură linie, este primul control recomandat de literatură,5 și toată lumea ajunge să o scrie. Ce nu face aproape nimeni este să măsoare cât valorează:
| plafon de ture | apeluri model | input tokens | 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 |
Citește ultimele două rânduri împreună. Dublarea plafonului de la 50 la 100 nu a dublat costul; l-a înmulțit cu 3,7. Input tokens au trecut de la 88.649 la 337.299, un factor de 3,8, pentru că tura poartă cu ea fiecare tură anterioară, iar totalul este . Un plafon de ture nu este un buton liniar. Este un buton pe rădăcina pătrată a celui mai rău caz, motiv pentru care ridicarea lui de la 20 la 100 „ca să fie sigur” este o decizie care merită pusă în preț înainte să o iei.
Stricarea doi: un plafon pe ture nu este un plafon pe bani
Link către secțiunea: Stricarea doi: un plafon pe ture nu este un plafon pe baniProblema cu un plafon de ture este că o tură nu are preț fix. Douăzeci de ture peste o transcriere scurtă au costat $0,038 mai sus. Douăzeci de ture cu un catalog de 200 de tools, un set de documente retrieved și patruzeci de mesaje de istoric costă de sute de ori mai mult, iar plafonul nu știe. Ce vrea operatorul să limiteze este factura.
Așa că bucla numără bani, folosind computeCost din Capitolul 16 peste tarifele citite acolo — $2,00 per milion de input tokens și $12,00 per milion de output, pentru modelul tarifat de-a lungul acestui 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);Același script scăpat de sub control, fără plafon de ture deloc, trei bugete:
| buget | ture atinse | cheltuit efectiv |
|---|---|---|
| $0,01 | 9 | $0,010780 |
| $0,05 | 24 | $0,051790 |
| $0,20 | 52 | $0,205398 |
Două lucruri merită numite. Primul: bugetul cumpără un număr diferit de ture de fiecare dată, ceea ce este ideea: limitează lucrul care contează pentru operator și lasă numărul de ture să cadă acolo unde îl pune transcrierea. Al doilea: fiecare rând depășește. Bugetul era $0,010 și s-au cheltuit $0,010780, pentru că verificarea rulează înaintea unei ture, iar prețul unei ture nu este cunoscut până după ce se termină. Nu poți limita cheltuiala exact; o poți limita la costul unei ture distanță. Spune asta în interfață în loc să te prefaci și pune verificarea înainte de apel, ca depășirea să fie o tură, nu două.
Cinci moduri de a ieși din buclă, nu unul
Link către secțiunea: Cinci moduri de a ieși din buclă, nu unulPână acum bucla are trei ieșiri, iar forma restului capitolului se vede. O rulare de producție se termină exact într-unul dintre cinci moduri, iar acestea nu sunt variații una ale alteia:
| cum se termină | cine a decis | ce ar trebui să facă apelantul |
|---|---|---|
| modelul a încetat să ceară | modelul | citește răspunsul |
| plafon de ture | tu, dinainte | ridică plafonul sau acceptă un rezultat parțial |
| buget epuizat | tu, dinainte | aprobă mai mulți bani sau acceptă un rezultat parțial |
| o eroare pe care nu o poți reîncerca | providerul sau un tool | repară deploymentul; trierea din Capitolul 14 decide |
| a intervenit un om | o persoană | așteaptă un verdict, apoi reia |
Strângerea lor într-un singur boolean este cea mai comună greșeală de design în acest fișier și este costisitoare într-un mod foarte concret: trei dintre cele cinci sunt reluabile, iar două nu. Un agent care și-a atins plafonul de ture are o transcriere validă, un rezultat parțial real și un pas următor; un agent care a primit un 401 nu are nimic din toate acestea. Așa că harness înregistrează motivul ca date:
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 };Stricarea trei: un tool eșuează
Link către secțiunea: Stricarea trei: un tool eșueazăCapitolul 18 s-a încheiat cu o afirmație fără număr: dă eroarea unui tool înapoi modelului ca rezultat de tool în loc să o ridici, iar modelul de obicei se repară singur. Iată numărul.
Un eșec, trei politici. Modelul scriptat ghicește un fișier care nu există; tool aruncă no such file: timeout.log. Call list_files to see what exists.
| ce face harness cu eroarea | ture | rulări tool | cost | ce a primit utilizatorul |
|---|---|---|---|---|
| o aruncă în afara buclei | 1 | 1 | $0,000756 | un stack trace |
returnează Error: the tool failed. | 2 | 1 | $0,001462 | „Nu am putut citi fișierul, așa că nu știu.” |
| returnează ce s-a întâmplat de fapt | 4 | 3 | $0,003550 | „errors.log menționează un timeout.” |
Al treilea rând costă de 4,7 ori cât primul și este singurul care răspunde la întrebare. Iar al doilea rând este cel interesant, pentru că este ce fac de fapt majoritatea codebase-urilor: eroarea a fost prinsă, bucla a supraviețuit, modelului i s-a spus că ceva a eșuat, nu ce, iar el a renunțat politicos. Diferența dintre rândurile doi și trei nu este error handling. Este o propoziție scrisă pentru un cititor.
Prin urmare, harness tratează un tool aruncat ca date și face formularea o politică:
} 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);
}Capitolul 18 a avertizat și despre cealaltă parte, iar și ea are un preț. Îndreaptă bucla către un tool care eșuează dintr-un motiv pe care niciun mesaj nu îl poate remedia — o citire pe care procesul nu are voie să o facă — iar modelul o reîncearcă la nesfârșit:
read a file the process may not open turns=12 toolruns=11 in=7,079 cost=$0.018622
status=max_turns_exceeded answer=""Unsprezece execuții identice ale unui apel care nu poate reuși, de 5,2 ori costul rulării care și-a revenit dintr-o eroare reparabilă, și nimic la final. Erorile sunt context; o eroare permanentă este context care otrăvește restul rulării. Distincția este trierea statusurilor din Capitolul 14 mutată cu un strat mai sus: o eroare pe care modelul poate acționa se întoarce în transcriere, iar o eroare pe care nu poate ar trebui să oprească rularea cu un motiv. Plafonul de ture este ce stă astăzi între tine și al doilea caz, ceea ce este un prag minim, nu o remediere.
Stricarea patru: același apel, de două ori
Link către secțiunea: Stricarea patru: același apel, de două oriAcum eșecul despre care majoritatea oamenilor presupun că nu se poate întâmpla. Modelele se repetă. Cere oricărei bucle să ruleze suficient de mult și vei vedea același tool cu aceleași argumente în două ture consecutive.
Măsurat față de baseline-ul aceleiași sarcini fără repetare:
| ture | rulări tool | cost | |
|---|---|---|---|
| sarcina, fără repetare | 2 | 1 | $0,001396 |
| aceeași sarcină, un apel repetat | 3 | 2 | $0,002446 |
| repetat, cu un cache de rezultate pe tools read-only | 3 | 1 | $0,002446 |
Apelul duplicat a costat $0,001050 în plus, o creștere de 75 %, iar aceasta este partea care îi surprinde pe oameni: cache-ul rezultatului nu a recuperat nimic din asta. Deduplication a economisit execuția tool, nu tura, pentru că până când codul tău observă repetarea, modelul a fost deja plătit pentru că a întrebat. Economia este reală când tool este lent, rate-limited sau facturat pe apel — și este zero pe linia de cost care a crescut.
Există o versiune mai rea. Aplică același cache unui tool care scrie, iar al doilea apel pur și simplu nu se întâmplă:
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"]Care dintre acestea este corect? Niciunul, în mod cognoscibil. Protocolul spune că acestea sunt două apeluri: poartă două valori tool_call_id diferite. Argumentele spun că ar putea fi unul. Un harness care decide comparând stringuri de argumente va înghiți într-o zi al doilea dintre două debitări identice, intenționate — iar Capitolul 14 a numit deja singurul mecanism care rezolvă asta onest, și anume o cheie de idempotency generată per operațiune logică de stratul care știe ce este operațiunea. Până când tool poartă una, defaultul apărabil este poarta read-only de mai sus: cache-uiește citirile, execută scrierile și lasă propria idempotency a scrierii să se ocupe de restul.
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;
}Stricarea cinci: șterge ceva
Link către secțiunea: Stricarea cinci: șterge cevaScriptul destructive listează fișierele și apoi cere să șteargă unul pe care sarcina nu l-a menționat niciodată. Nimic din bucla de până acum nu l-ar opri.
Un tool marcat needsApproval nu eșuează și nu continuă. Oprește rularea și returnează controlul, cu tot ce îi trebuie unei persoane ca să decidă:
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."Acesta este întregul mecanism, iar motivul pentru care este un return și nu un callback este secțiunea următoare: între oprire și verdict, procesul s-ar putea să nu mai existe.
Dar mai întâi, măsurătoarea la care nu se așteaptă nimeni. O respingere nu este absența unui rezultat — transcrierea are un slot cheiat de tool_call_id și ceva trebuie să intre în el. Rulează aceeași respingere de două ori, schimbând doar ce spune acel ceva:
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."Nu s-a șters nimic în niciuna dintre rulări, iar în a doua utilizatorului i se spune că s-a șters. Sistemul de permisiuni a funcționat perfect; raportul este o minciună. Este același mecanism ca tabelul erorilor de tool, ajuns undeva unde contează mult mai mult — un om a spus nu, acțiunea a fost blocată corect, iar rezumatul agent contrazice realitatea pentru că refuzul nu a fost scris niciodată acolo unde citește modelul. Regula care rezultă este scurtă: orice decide codul tău despre un tool call, scrie decizia în transcriere, în cuvinte. Capitolul 30 revine la asta din perspectiva securității, unde este diferența dintre un audit trail și ficțiune.
Stricarea șase: procesul moare
Link către secțiunea: Stricarea șase: procesul moareO aprobare durează minute sau ore. Un deploy durează secunde. Dacă rularea trăiește într-o variabilă locală în interiorul unui request HTTP, fiecare restart este o rulare pierdută și fiecare aprobare este o cursă.
Așa că rularea nu este o closure. Este un obiect simplu serializabil — mesaje, număr de ture, cost, status, întrerupere, lista de call ids aprobate — iar bucla este o funcție pură peste el. Acea singură constrângere este ce face persistența o preocupare de o singură linie:
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"));Întrebarea de corectitudine nu este salvarea. Este ce se întâmplă la întoarcere, iar răspunsul naiv te taxează de două ori. Dacă procesul a murit după ce modelul a cerut un tool, dar înainte ca rezultatul să fie scris, o reluare care începe prin a chema din nou modelul plătește pentru o tură pe care o are deja — iar dacă începe prin a rerula tools, face o scriere de două ori.
Remedierea este să faci bucla să înceapă întrebând transcrierea ce este outstanding:
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));
}Fiecare iterație golește mai întâi pending și întreabă modelul doar când nu mai există nimic outstanding. Reluarea devine același traseu de cod ca cel normal, la fel și aprobarea — un apel aprobat este pur și simplu un apel pending care acum are voie să ruleze. Omoară procesul în mijlocul sarcinii și repornește-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.logDouă execuții de tool peste două procese pentru o sarcină care are nevoie de două, iar costul final este identic cu rularea care nu a căzut niciodată. Costul se acumulează peste restart pentru că era în stare, nu într-o variabilă.
Stricarea șapte: trei minute de tăcere
Link către secțiunea: Stricarea șapte: trei minute de tăcerescan_archive durează trei secunde aici și ține locul unui tool care durează trei minute în producție. Două lucruri lipsesc cât timp rulează: utilizatorul nu are idee că se întâmplă ceva, iar butonul Stop nu face nimic.
Ambele au aceeași remediere, iar aceasta este AbortSignal din Capitolul 14 împins cu un nivel mai adânc. Semnalul nu este doar pentru fetch — este trecut în tool, iar un tool bine scris îl respectă:
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"Două milisecunde de la click la oprire, pentru că sleep-ul din interiorul tool ascultă același semnal ca fetch-ul. Trece-l doar în fetch și același buton Stop așteaptă trei secunde — lungimea tool — iar rularea „se anulează” după ce lucrul pe care îl anula s-a terminat deja. Cancellation care nu este plumbed până jos este un spinner care spune cuvântul corect.
Trace-ul și de ce nu este un log
Link către secțiunea: Trace-ul și de ce nu este un logHarness emite o linie per eveniment, iar vocabularul este suficient de mic ca să fie memorat: 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}Trei proprietăți fac din asta un trace, nu logging. Fiecare linie poartă run id, deci o rulare care se întinde peste trei procese și două zile este o singură interogare. Fiecare linie turn poartă propriile numărători de token și costul curent, astfel încât „de ce a costat rularea asta patruzeci de dolari” primește răspuns după fapt, nu doar reproductibil în teorie. Iar run_stopped poartă motivul, câmpul care transformă un tichet de suport într-un răspuns de o linie: un agent care s-a oprit la buget și un agent care a căzut arată identic din exterior și au nevoie de răspunsuri opuse.
Aritmetica latenței
Link către secțiunea: Aritmetica latențeiCapitolul 13 a măsurat timpul până la primul token pe hardware pe care îl deții. Capitolul 14 l-a măsurat printr-un socket. Un agent îl multiplică, iar multiplicatorul este un număr pe care nu l-a ales nimeni:
Aceeași sarcină de trei ture, schimbând doar latența providerului:
| latență provider per tură | wall clock, 3 ture |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2.413 ms |
Harness în sine contribuie cu cincisprezece milisecunde la o rulare de trei ture. Tot restul este înmulțit cu un număr pe care nu îl controlezi — setat în interiorul unui serving scheduler care îți batch-uiește requestul cu requesturile străinilor6 — iar este ales de model. De aceea streamingul din Capitolul 14 contează mai mult aici decât într-un chat și ajută mai puțin: poți stream-ui tura finală, iar cele patru ture dinaintea ei sunt tăcere dacă harness nu emite progres. Este și întregul argument pentru evenimentul tool_progress de mai sus — într-un agent, unitatea onestă de feedback nu este token, ci pasul.
Același harness, cu un model real în spatele portului
Link către secțiunea: Același harness, cu un model real în spatele portuluiTot ce a fost mai sus a rulat împotriva unui provider scriptat, ceea ce dovedește harness și nu dovedește nimic despre modele. Așa că schimbă o linie — cusătura din Capitolul 14, LLM_BASE_URL — și îndreaptă codul identic către un Qwen2.5-0.5B-Instruct local, cu aceleași patru tools. Șase sarcini peste aceleași trei fișiere:
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,908msTrei constatări, iar a treia este motivul pentru care există această secțiune.
Fiecare sarcină s-a terminat în exact două ture. Plafonul de ture nu s-a declanșat, bugetul nu s-a declanșat, iar singura ieșire a buclei a fost modelul producând proză. Un model de jumătate de miliard de parametri nu iterează; răspunde la a doua respirație, indiferent dacă are sau nu ce îi trebuie. Numărul de ture este o proprietate a modelului, nu a buclei tale.
Tura medie a durat 6.908 milisecunde, deci tabelul de latență de mai sus nu este o jucărie: la dimensiunea asta, o rulare ipotetică de opt ture înseamnă aproape un minut de wall clock fără nimic pe ecran.
Iar răspunsurile sunt greșite. Cel mai mare fișier este errors.log; modelul a listat fișierele, nu le-a citit niciodată și a numit unul oricum. Prima sarcină a ghicit un nume de fișier, i s-a spus că nu există și a tras concluzia. Harness a executat impecabil în toate cele șase rulări. Un harness face un agent guvernabil, nu corect — Capitolul 29 este cum afli care dintre ele, iar Capitolul 30 este cât costă când nimeni nu a făcut-o.
Subagents, numiți aici și taxați mai târziu
Link către secțiunea: Subagents, numiți aici și taxați mai târziuUn tool din catalog poate avea o altă rulare în spate. Interfața este cea din Capitolul 18 — o schemă și un endpoint — iar un agent întreg încape în spatele ei pentru că interfața este îngustă:
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";
},
};Trei lucruri sunt deja corecte în acele zece linii și toate trei sunt consecințe ale deciziilor luate mai sus: copilul are propria fereastră, deci transcrierea părintelui primește un rezumat, nu tot ce a citit copilul; are propriile limite, deci un copil scăpat de sub control nu poate cheltui bugetul părintelui; și moștenește semnalul, deci un singur Stop anulează arborele. De ce o fereastră curată este scopul, nu un efect secundar, este Capitolul 24; cele cinci tipare de orchestrare — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — și handoff sunt Capitolul 25.
Unde sunt frameworkurile și de ce acest curs nu a folosit unul
Link către secțiunea: Unde sunt frameworkurile și de ce acest curs nu a folosit unulNimic de mai sus nu ar trebui citit ca argument împotriva bibliotecilor. Măsurat pe 7 septembrie 2026, pentru luna încheiată pe 29 august:7
| package | descărcări în acea lună | ce îți oferă |
|---|---|---|
ai (Vercel AI SDK) | 89.385.860 | ToolLoopAgent, stopWhen, aprobare tool, step hooks |
@anthropic-ai/claude-agent-sdk | 41.558.352 | Claude Code harness ca bibliotecă: buclă, sesiuni, hooks, permisiuni, subagents8 |
@langchain/langgraph | 12.812.815 | bucla ca graf explicit de stare |
langchain | 11.359.058 | chains, agents, integrări |
@openai/agents | 6.093.155 | agents, handoffs, guardrails |
@mastra/core | 5.914.502 | agents, workflows, memorie |
Motivul pentru care acest curs scrie bucla manual în loc să predea una dintre ele este declarat, nu sugerat, și este măsurabil. În cele douăsprezece luni până la 7 septembrie 2026, ai a publicat 945 de versiuni și a trecut de la major 5 la major 7, iar clasa sa agent este încă exportată ca Experimental_Agent; langchain a publicat 132 de versiuni în aceeași fereastră; @openai/agents a publicat 83 și este încă pe 0.x, la cincisprezece luni după prima lansare.7 Un capitol scris împotriva oricăruia dintre acele API-uri se învechește într-un sezon, iar acesta este publicat în treizeci și trei de limbi, așa că fiecare reeditare costă întreaga traducere. Ce se află dedesubtul tuturor nu se mișcă: o buclă, o regulă de oprire, un catalog, un executor, ceva stare.
Iar implementarea de referință este de acord cu acest capitol în privința părții care contează. În versiunea ai 7.0.93, ieșirea buclei nu este un număr — este stopWhen, o listă de predicate, dintre care un număr de pași este doar unul:3
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>; // exported as stepCountIsOprirea este plurală în cea mai folosită implementare a acestei bucle, din același motiv pentru care este plurală în cele o sută nouăzeci și șase de linii de mai sus.
Unde mergem mai departe
Link către secțiunea: Unde mergem mai departeAcum ai un harness: o buclă, un catalog, un executor, cinci ieșiri, o rulare persistată, un semnal care ajunge la tools și un trace cu run id pe fiecare linie. Capitolele 24, 25, 29 și 30 construiesc pe acest fișier, iar 26 până la 28 pe ce poate atinge el.
Mai are o singură problemă, iar măsurătorile de mai sus au indicat-o tot drumul. Uită-te încă o dată la tabelul rulării scăpate de sub control: 3.431 input tokens la opt ture, 337.299 la o sută. Uită-te la rularea care funcționează: 204, 269, 342. Fiecare tură retrimite întreaga transcriere, deci contextul unui agent se umple cu propria istorie — iar modelul folosește mai prost capătul îndepărtat al unei ferestre lungi decât capătul apropiat, motiv pentru care un agent bun la tura cinci este unul confuz la tura patruzeci.
Un plafon de ture nu repară asta. Doar te oprește din a plăti ca să privești cum se întâmplă. Ce o repară este să decizi, la fiecare tură, care tokens merită fereastra: ce să compactezi, ce să muți într-o notă pe care agent o poate fetch-ui, ce să dai unui subagent cu o fereastră curată și care definiții de tool merită taxa lor permanentă. Capitolul 24 măsoară unde se duce de fapt fereastra — iar surpriza este că nu este conversația.
Surse și metodă
Link către secțiunea: Surse și metodăFiecare număr din acest capitol a ieșit din cele două servere descrise mai sus, pe Node 22 peste o interfață loopback: un provider scriptat care numără tokens cu encodingul o200k_base și Qwen/Qwen2.5-0.5B-Instruct în spatele unui endpoint cu aceeași formă, greedy decoding, pe CPU. Costurile sunt calculate din numărători măsurate de tokens la tarifele citite de Capitolul 16 pe 6 septembrie 2026 — $2,00 per milion de input tokens și $12,00 per milion de output — și niciun request din acest capitol nu a mers către un endpoint plătit. Răspunsurile modelului local sunt răspunsurile unui model mic; citește-le ca dovadă despre buclă, care este identică în ambele cazuri, nu ca benchmark pentru ce fac modelele actuale.
Referințe
Link către secțiunea: Referințe-
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). Împletirea urmelor de raționament cu acțiuni pe care o implementează bucla și sursa observației că acționarea lasă un model să „handle exceptions” — exact ce măsoară tabelul erorilor de tool de mai sus. ↩
-
Sumers, T. R., Yao, S., Narasimhan, K. și Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). Tratarea formală a ceea ce bucla de mai sus face informal: componente modulare de memorie, un spațiu de acțiuni structurat care acoperă memoria internă și mediile externe și „un proces generalizat de luare a deciziilor pentru alegerea acțiunilor”. Citește-o pentru vocabularul care îi lipsește termenului din industrie — în special separarea memoriei working, episodic, semantic și procedural, a cărei umbră practică este tabelul cu trei store-uri din Capitolul 24. ↩
-
ai(Vercel AI SDK) versiunea 7.0.93, publicată pe 4 septembrie 2026; declarații de tip citite dincdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.tspe 7 septembrie 2026. Fișierul de 397 KB conține zero apariții ale șiruluiharness. Clasa agent estedeclare class ToolLoopAgent, exportată atât caToolLoopAgent, cât și caExperimental_Agent;declare function isStepCount(stepCount: number)— exportat castepCountIs— este citat verbatim mai sus;type StopConditioneste afișat fără al doilea parametru de tip (RUNTIME_CONTEXT extends Context = Context), care este singura elidare din extras, la fel ca forma luistopWhen?: Arrayable<StopCondition<...>>pegenerateTextșistreamText. Același fișier declarătoolApproval,ToolApprovalStatus,prepareStepșirepairToolCall, ceea ce înseamnă că implementarea de referință a ajuns independent la approval gates, pregătire per-step și repararea erorilor. ↩ ↩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). Rezumatul numește artefactul un „evaluation framework” cu 2.294 de probleme și nu folosește niciodată cuvântul „harness”; README-ul propriu al proiectului (
github.com/SWE-bench/SWE-bench, citit pe 7 septembrie 2026) îl folosește de cinci ori, mereu ca „evaluation harness”, iar entry point estepython -m swebench.harness.run_evaluation. Acesta este celălalt sens al cuvântului: o schelă care ține agent pe loc și îl punctează, nu bucla care îl rulează. ↩ -
Anthropic, Building effective agents, 19 decembrie 2024,
anthropic.com/engineering/building-effective-agents, citit pe 7 septembrie 2026. Modelul augmentat ca bloc de construcție, agent ca LLM „using tools based on environmental feedback in a loop” și recomandarea de condiții de oprire „such as a maximum number of iterations” pentru menținerea controlului. Capitolul 22 îi citează integral definiția. ↩ -
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). Cealaltă buclă — serving scheduler care îți batch-uiește requestul cu requesturile străinilor și gestionează KV cache din Capitolul 13. Merită să știi că există tocmai pentru că nu este a ta: latența pe care o multiplică harness este setată în interiorul ei, iar oricât ai lucra la bucla ta nu o mișcă. ↩
-
Numărători de descărcări din registrul npm,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, o fereastră explicită în locul celei rollinglast-month, și istorice de release dinregistry.npmjs.org/<package>; ambele interogate pe 7 septembrie 2026. Numărătorile de release sunt numărul de versiuni publicate în cele douăsprezece luni până la acea dată, inclusiv buildurile canary:ai945 (cea mai nouă 7.0.93 pe 2026-09-04, cu versiunile majore 5, 6 și 7 apărând toate în interiorul ferestrei),langchain132 (cea mai nouă 1.5.10 pe 2026-08-20),@openai/agents83 (cea mai nouă 0.17.0 pe 2026-08-19, prima publicată pe 2025-06-03). ↩ ↩2 -
Claude Agent SDK (
@anthropic-ai/claude-agent-sdk) este Claude Code harness împachetat ca bibliotecă — agent loop, tools integrate pentru fișiere și shell, context management, sesiuni, hooks, permisiuni și subagents — documentat lacode.claude.com/docs/en/agent-sdk. Este cel mai apropiat lucru de o descriere publicată a fiecărui mecanism pe care acest capitol îl construiește manual și merită citit lângă propria implementare pentru piesele pe care le numește și la care acest capitol doar face semn. ↩