Sari la conținut
23/30Capitolul 23 din 30

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.

Capitolul 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.

mock-provider.mjsJS
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.

Iată întreaga idee, înainte de oricare dintre piesele care o fac supraviețuibilă.

loop.tsTS
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:

TEXT
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, 342

Trei 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 tureapeluri modelinput tokenscost
883.431$0,009070
202016.259$0,038038
505088.649$0,191098
100100337.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 nn poartă cu ea fiecare tură anterioară, iar totalul este Θ(n2)\Theta(n^2). 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 bani

Problema 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:

harness.tsTS
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:

bugetture atinsecheltuit efectiv
$0,019$0,010780
$0,0524$0,051790
$0,2052$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ă.

Pâ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 decisce ar trebui să facă apelantul
modelul a încetat să cearămodelulcitește răspunsul
plafon de turetu, dinainteridică plafonul sau acceptă un rezultat parțial
buget epuizattu, dinainteaprobă mai mulți bani sau acceptă un rezultat parțial
o eroare pe care nu o poți reîncercaproviderul sau un toolrepară deploymentul; trierea din Capitolul 14 decide
a intervenit un omo 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:

harness.tsTS
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 };

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 eroareaturerulări toolcostce a primit utilizatorul
o aruncă în afara buclei11$0,000756un stack trace
returnează Error: the tool failed.21$0,001462„Nu am putut citi fișierul, așa că nu știu.”
returnează ce s-a întâmplat de fapt43$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 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ă:

harness.tsTS
} 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:

TEXT
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.

Acum 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:

turerulări toolcost
sarcina, fără repetare21$0,001396
aceeași sarcină, un apel repetat32$0,002446
repetat, cu un cache de rezultate pe tools read-only31$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ă:

TEXT
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.

harness.tsTS
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;
}

Scriptul 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ă:

harness.tsTS
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) });
}
TEXT
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:

TEXT
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.

O 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:

harness.tsTS
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:

harness.tsTS
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:

TEXT
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.log

Două 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ă.

scan_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ă:

harness.tsTS
result = await tool.run(JSON.parse(c.function.arguments), {
  signal,                                                                     
  progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});
TEXT
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.

Harness 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.

TEXT
{"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.

Capitolul 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:

TrunN(tmodel+ttools)T_{\text{run}} \approx N \cdot \left( t_{\text{model}} + t_{\text{tools}} \right)

Aceeași sarcină de trei ture, schimbând doar latența providerului:

latență provider per turăwall clock, 3 ture
0 ms15 ms
200 ms615 ms
800 ms2.413 ms

Harness în sine contribuie cu cincisprezece milisecunde la o rulare de trei ture. Tot restul este NN î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 NN 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 portului

Tot 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:

TEXT
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,908ms

Trei 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.

Un 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ă:

subagent.tsTS
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 unul

Nimic de mai sus nu ar trebui citit ca argument împotriva bibliotecilor. Măsurat pe 7 septembrie 2026, pentru luna încheiată pe 29 august:7

packagedescărcări în acea lunăce îți oferă
ai (Vercel AI SDK)89.385.860ToolLoopAgent, stopWhen, aprobare tool, step hooks
@anthropic-ai/claude-agent-sdk41.558.352Claude Code harness ca bibliotecă: buclă, sesiuni, hooks, permisiuni, subagents8
@langchain/langgraph12.812.815bucla ca graf explicit de stare
langchain11.359.058chains, agents, integrări
@openai/agents6.093.155agents, handoffs, guardrails
@mastra/core5.914.502agents, 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

ai-sdk.tsTS
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>;   // exported as stepCountIs

Oprirea 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.

Acum 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.


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.

  1. 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.

  2. 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.

  3. ai (Vercel AI SDK) versiunea 7.0.93, publicată pe 4 septembrie 2026; declarații de tip citite din cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts pe 7 septembrie 2026. Fișierul de 397 KB conține zero apariții ale șirului harness. Clasa agent este declare class ToolLoopAgent, exportată atât ca ToolLoopAgent, cât și ca Experimental_Agent; declare function isStepCount(stepCount: number) — exportat ca stepCountIs — este citat verbatim mai sus; type StopCondition este afișat fără al doilea parametru de tip (RUNTIME_CONTEXT extends Context = Context), care este singura elidare din extras, la fel ca forma lui stopWhen?: Arrayable<StopCondition<...>> pe generateText și streamText. Același fișier declară toolApproval, ToolApprovalStatus, prepareStep și repairToolCall, ceea ce înseamnă că implementarea de referință a ajuns independent la approval gates, pregătire per-step și repararea erorilor. 2

  4. 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 este python -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ă.

  5. 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.

  6. 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ă.

  7. 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 rolling last-month, și istorice de release din registry.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: ai 945 (cea mai nouă 7.0.93 pe 2026-09-04, cu versiunile majore 5, 6 și 7 apărând toate în interiorul ferestrei), langchain 132 (cea mai nouă 1.5.10 pe 2026-08-20), @openai/agents 83 (cea mai nouă 0.17.0 pe 2026-08-19, prima publicată pe 2025-06-03). 2

  8. 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 la code.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.

Gata să lași LIA să aleagă?

Construiește cu toate modelele AI într-un singur loc — începe gratuit azi.