Byg et agent harness: loopet og dets fem veje ud
En loop på femten linjer, der virker første gang — og derefter brydes syv gange med vilje, fra en runaway der kostede 77× mere.
På denne side
Start med den ærlige del, for ingen andre siger det: »harness« er jargon, ikke en standard. Der findes ingen specifikation, ingen komité, ingen reference-definition. De fire artikler, dette kapitel citerer — ReAct,1 CoALA,2 SWE-bench og vLLM — bruger ikke ordet én eneste gang i deres abstracts. Den mest downloadede implementering af tingen, Vercels ai-pakke med 89,4 millioner downloads om måneden, bruger det heller ikke: strengen harness optræder nul gange i de 397 KB type-deklarationer, der følger med version 7.0.93.3 Det ene sted, hvor ordet er bærende, betyder det noget helt andet. SWE-bench siger »harness« fem gange i sin README, altid som evaluation harness — det containeriserede stillads, der anvender en patch og kører testene — og dets Python-modul hedder bogstaveligt swebench.harness.run_evaluation.4
Så to forskellige ting deler et navn. Et evaluation harness holder agent stille og scorer den. Et agent harness er programmet, der kører agent: det kalder modellen, udfører det modellen beder om, beslutter hvornår der skal stoppes, og holder tilstanden imellem. Dette kapitel bygger den anden slags på under to hundrede linjer TypeScript, helt uden framework.
Selve loopet er femten linjer, og det virker i første forsøg. Alt derefter er en måde at forlade det på.
Vis detaljer
Det dette kapitel kræver fra de tidligere.
- Kapitel 14 til klienten: deadlines, statustriage, annullering, idempotency keys og mock provider-teknikken, der bruges igen her.
- Kapitel 16 til aritmetikken: input tokens vokser med kvadratet på samtalen, og satserne nedenfor er dem, der blev aflæst dér den 6. september 2026.
- Kapitel 18 til værktøjskataloget: et schema modellen ser, et endpoint den aldrig ser, og reglen om at fejl er kontekst snarere end exceptions.
- Kapitel 22 til det loop, dette kapitel arver, og til de to publicerede definitioner af »agent«, som er uenige med hinanden.
Ingen tensors her. Dette er kursets andet afhængighedsknudepunkt: Kapitel 24, 25, 29 og 30 kører på filen nedenfor, og 26 til 28 bygger på det, den kan nå.
En provider du kan script'e
Link til afsnittet: En provider du kan script'eKapitel 14 kunne ikke skrives mod en rigtig provider, fordi du ikke kan bede en om en 429 på et valgt tidspunkt. Dette kapitel har samme problem i en anden form: du kan ikke bede en rigtig model om at løbe løbsk, eller om at anmode om det identiske værktøj to gange i træk, på kommando og reproducerbart.
Så det første program er en scripted provider: et endpoint med formen af en chat completions API, hvis svar er en funktion af turindekset og af det, værktøjerne indtil videre har returneret. Den tæller tokens med en rigtig byte-pair encoder, så pengene nedenfor er aritmetik og ikke pynt.
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);To linjer bærer designet. Turindekset er afledt af samtalen, ikke gemt i en variabel, så provideren er stateless, og et run kan dræbes og genoptages mod den. Og recover læser værktøjsresultaterne, før den beslutter sig: en scripted model, der læser sit eget transcript, er det minimum, der skal til for at måle, om harness gav den noget, der var værd at læse.
Kataloget er Kapitel 18's: fire værktøjer over tre filer: list_files, read_file, delete_file — markeret needsApproval — og scan_archive, som med vilje er langsomt.
Loopet der virker
Link til afsnittet: Loopet der virkerHer er hele idéen, før nogen af de dele, der gør den overlevelig.
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 });
}
}Peg det mod den scripted provider, og det gør præcis det, det ser ud til at gøre:
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, 342Tre ture, to værktøjsudførsler, en kvart amerikansk cent. Bemærk den sidste linje: 204, 269, 342. Hver tur sender alt før den igen, hvilket er Kapitel 16's kvadratiske regning, der ankommer et sted, hvor ingen har skrevet noget. Resten af dette kapitel er det, der sker, når den linje ikke stopper med at vokse.
Brud ét: opgaven der aldrig slutter
Link til afsnittet: Brud ét: opgaven der aldrig slutterPeg det samme loop mod runaway-scriptet — en model, der beder om et værktøj i hver eneste tur og aldrig udsender prosa — og den markerede return aktiveres aldrig. Der er ingen anden udgang. Programmet kører, indtil processen dør, eller kreditkortet gør.
Løsningen er én linje, det er den første kontrol litteraturen anbefaler,5 og alle skriver den før eller siden. Det næsten ingen gør, er at måle, hvad den er værd:
| turgrænse | modelkald | input tokens | omkostning |
|---|---|---|---|
| 8 | 8 | 3.431 | $0.009070 |
| 20 | 20 | 16.259 | $0.038038 |
| 50 | 50 | 88.649 | $0.191098 |
| 100 | 100 | 337.299 | $0.702198 |
Læs de sidste to rækker sammen. At fordoble grænsen fra 50 til 100 fordoblede ikke omkostningen; det gangede den med 3,7. Input tokens gik fra 88.649 til 337.299, en faktor 3,8, fordi tur bærer alle tidligere ture med sig, og totalen er . En turgrænse er ikke en lineær knap. Det er en knap på kvadratroden af dit worst case, og derfor er det en beslutning, der bør prissættes, før du hæver den fra 20 til 100 »bare for en sikkerheds skyld«.
Brud to: en grænse på ture er ikke en grænse på penge
Link til afsnittet: Brud to: en grænse på ture er ikke en grænse på pengeProblemet med en turgrænse er, at en tur ikke har en fast pris. Tyve ture over et kort transcript kostede $0.038 ovenfor. Tyve ture med et katalog på 200 værktøjer, et hentet dokumentsæt og fyrre historikbeskeder koster hundredvis af gange mere, og grænsen ved det ikke. Det operatøren vil begrænse, er regningen.
Så loopet tæller penge ved at bruge Kapitel 16's computeCost mod de satser, der blev aflæst dér — $2.00 pr. million input tokens og $12.00 pr. million output for den model, der prissættes gennem hele kurset:
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);Samme runaway-script, ingen turgrænse overhovedet, tre budgetter:
| budget | nåede ture | faktisk brugt |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $0.205398 |
To ting er værd at navngive. For det første køber budgettet et forskelligt antal ture hver gang, hvilket er pointen: det begrænser det, operatøren bekymrer sig om, og lader antallet af ture falde dér, hvor transcriptet lægger det. For det andet overskrider hver række budgettet. Budgettet var $0.010, og $0.010780 blev brugt, fordi tjekket kører før en tur, og prisen på en tur ikke kendes, før den er slut. Du kan ikke begrænse forbrug præcist; du kan begrænse det til inden for én turs omkostning. Sig det i interfacet i stedet for at lade som om, og læg tjekket før kaldet, så overskridelsen er én tur og ikke to.
Fem måder at forlade loopet på, ikke én
Link til afsnittet: Fem måder at forlade loopet på, ikke énNu har loopet tre udgange, og formen på resten af kapitlet er synlig. Et produktions-run ender på præcis én af fem måder, og de er ikke variationer af hinanden:
| hvordan det ender | hvem besluttede | hvad kalderen bør gøre |
|---|---|---|
| modellen stoppede med at spørge | modellen | læs svaret |
| turgrænse | dig, på forhånd | hæv grænsen, eller accepter et delvist resultat |
| budget opbrugt | dig, på forhånd | godkend flere penge, eller accepter et delvist resultat |
| en fejl du ikke kan retry | provideren eller et værktøj | ret deploymentet; Kapitel 14's triage beslutter |
| et menneske greb ind | en person | vent på en afgørelse, og genoptag derefter |
At kollapse disse til én boolean er den mest almindelige designfejl i denne fil, og den er dyr på en specifik måde: tre af de fem kan genoptages, og to kan ikke. En agent, der ramte sin turgrænse, har et gyldigt transcript, et reelt delresultat og et næste skridt; en agent, der fik en 401, har ingen af delene. Derfor registrerer harness årsagen som data:
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 };Brud tre: et værktøj fejler
Link til afsnittet: Brud tre: et værktøj fejlerKapitel 18 sluttede med en påstand uden et tal: giv et værktøjs fejl tilbage til modellen som et værktøjsresultat i stedet for at kaste den, og modellen retter normalt sig selv. Her er tallet.
Én fejl, tre policies. Den scripted model gætter på en fil, der ikke findes; værktøjet kaster no such file: timeout.log. Call list_files to see what exists.
| hvad harness gør med fejlen | ture | værktøjskørsler | omkostning | hvad brugeren fik |
|---|---|---|---|---|
| kaster den ud af loopet | 1 | 1 | $0.000756 | en stack trace |
returnerer Error: the tool failed. | 2 | 1 | $0.001462 | »Jeg kunne ikke læse filen, så jeg ved det ikke.« |
| returnerer det, der faktisk skete | 4 | 3 | $0.003550 | »errors.log nævner en timeout.« |
Den tredje række koster 4,7 gange så meget som den første og er den eneste, der besvarer spørgsmålet. Og den anden række er den interessante, fordi det er det, de fleste codebases faktisk gør: fejlen blev fanget, loopet overlevede, modellen fik at vide at noget fejlede og ikke hvad, og den gav høfligt op. Forskellen mellem række to og tre er ikke error handling. Det er en sætning skrevet til en læser.
Derfor behandler harness et kastet værktøj som data og gør formuleringen til en policy:
} 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);
}Kapitel 18 advarede også om den anden side, og den har også en pris. Peg loopet mod et værktøj, der fejler af en grund, ingen besked kan rette — en læsning processen ikke har lov til at udføre — og modellen prøver igen for evigt:
read a file the process may not open turns=12 toolruns=11 in=7,079 cost=$0.018622
status=max_turns_exceeded answer=""Elleve identiske udførsler af et kald, der ikke kan lykkes, 5,2 gange omkostningen ved det run, der kom sig over en fejl, der kunne rettes, og intet til sidst. Fejl er kontekst; en permanent fejl er kontekst, der forgifter resten af runnet. Skellet er Kapitel 14's statustriage flyttet et lag op: en fejl modellen kan handle på, går tilbage i transcriptet, og en fejl den ikke kan handle på, bør stoppe runnet med en årsag. Turgrænsen er det, der står mellem dig og det andet tilfælde i dag, hvilket er et gulv og ikke en løsning.
Brud fire: det samme kald, to gange
Link til afsnittet: Brud fire: det samme kald, to gangeNu den fejl, de fleste antager ikke kan ske. Modeller gentager sig selv. Bed et hvilket som helst loop om at køre længe nok, og du vil se det identiske værktøj med de identiske argumenter i to på hinanden følgende ture.
Målt mod baseline for samme opgave uden gentagelsen:
| ture | værktøjskørsler | omkostning | |
|---|---|---|---|
| opgaven, ingen gentagelse | 2 | 1 | $0.001396 |
| samme opgave, ét kald gentaget | 3 | 2 | $0.002446 |
| gentaget, med en resultat-cache på read-only-værktøjer | 3 | 1 | $0.002446 |
Det duplikerede kald kostede $0.001050 ekstra, en stigning på 75 %, og her er den del, der overrasker folk: caching af resultatet indhentede intet af det. Deduplication sparede værktøjsudførelsen og ikke turen, fordi modellen allerede er blevet betalt for at spørge, når din kode opdager gentagelsen. Besparelsen er reel, når værktøjet er langsomt, rate-limited eller afregnes pr. kald — og den er nul på den linjepost, der voksede.
Der findes en værre version. Anvend samme cache på et værktøj, der skriver, og det andet kald sker stiltiende ikke:
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"]Hvilken af dem er korrekt? Ingen af dem, vidbart. Protokollen siger, at dette er to kald: de bærer to forskellige tool_call_id-værdier. Argumenterne siger, at de måske er ét. Et harness, der beslutter ved at sammenligne argumentstrenge, vil en dag sluge det andet af to identiske, tilsigtede opkrævninger — og Kapitel 14 har allerede navngivet den eneste mekanisme, der løser dette ærligt, nemlig en idempotency key genereret pr. logisk operation af det lag, der ved, hvad operationen er. Indtil værktøjet bærer en, er den forsvarlige standard read-only-gaten ovenfor: cache læsninger, udfør skrivninger, og lad skrivningens egen idempotency håndtere resten.
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;
}Brud fem: den sletter noget
Link til afsnittet: Brud fem: den sletter nogetdestructive-scriptet oplister filerne og beder derefter om at slette en, som opgaven aldrig nævnte. Intet i loopet indtil nu ville stoppe det.
Et værktøj markeret needsApproval fejler ikke og fortsætter ikke. Det stopper runnet og giver kontrollen tilbage med alt det, en person har brug for for at beslutte:
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."Det er hele mekanismen, og grunden til at det er en return snarere end et callback, er næste afsnit: mellem stoppet og afgørelsen findes processen måske ikke længere.
Men først målingen, ingen forventer. En afvisning er ikke fraværet af et resultat — transcriptet har en slot keyed by tool_call_id, og der skal noget ind i den. Kør den samme afvisning to gange, og ændr kun hvad det noget siger:
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."Intet blev slettet i nogen af kørslerne, og i den anden får brugeren at vide, at det blev. Tilladelsessystemet fungerede perfekt; rapporten er en løgn. Det er samme mekanisme som tabellen med værktøjsfejl, men den dukker op et sted, hvor det betyder langt mere — et menneske sagde nej, handlingen blev korrekt blokeret, og agentens summary modsiger virkeligheden, fordi afslaget aldrig blev skrevet ned dér, hvor modellen læser. Reglen, der falder ud af dette, er kort: uanset hvad din kode beslutter om et værktøjskald, så skriv beslutningen ind i transcriptet med ord. Kapitel 30 vender tilbage til dette fra sikkerhedssiden, hvor det er forskellen mellem et audit trail og fiktion.
Brud seks: processen dør
Link til afsnittet: Brud seks: processen dørEn approval tager minutter eller timer. Et deploy tager sekunder. Hvis runnet lever i en lokal variabel inde i en HTTP request, er hver genstart et tabt run, og hver approval er et race.
Så runnet er ikke en closure. Det er et almindeligt serialiserbart objekt — beskeder, turantal, omkostning, status, interruption, listen over godkendte call ids — og loopet er en ren funktion over det. Den ene begrænsning er det, der gør persistence til en énlinjes bekymring:
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"));Korrekthedsspørgsmålet er ikke at gemme. Det er, hvad der sker på vej ind igen, og det naive svar opkræver dig dobbelt. Hvis processen døde, efter modellen bad om et værktøj, men før resultatet blev skrevet, betaler en genoptagelse, der starter med at kalde modellen igen, for en tur, den allerede har — og hvis den starter med at køre værktøjerne igen, udfører den en skrivning to gange.
Løsningen er at få loopet til at begynde med at spørge transcriptet, hvad der er 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));
}Hver iteration dræner pending først og spørger kun modellen, når der ikke er noget outstanding. Resume bliver samme code path som den normale, og det samme gør approval — et godkendt kald er blot et pending kald, der nu har lov til at køre. Dræb processen midt i opgaven, og genstart den:
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.logTo værktøjsudførsler over to processer for en opgave, der har brug for to, og den endelige omkostning er identisk med det run, der aldrig crashede. Omkostningen akkumulerer på tværs af genstarten, fordi den lå i state, ikke i en variabel.
Brud syv: tre minutters stilhed
Link til afsnittet: Brud syv: tre minutters stilhedscan_archive tager tre sekunder her og står i stedet for det værktøj, der tager tre minutter i produktion. To ting mangler, mens det kører: brugeren har ingen idé om, at der sker noget, og Stop-knappen gør ingenting.
Begge er samme løsning, og det er Kapitel 14's AbortSignal skubbet et niveau dybere. Signalet er ikke kun til fetch — det sendes ind i værktøjet, og et velskrevet værktøj respekterer det:
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"To millisekunder fra klik til stop, fordi sleep inde i værktøjet lytter til det samme signal som fetch. Thread det kun ind i fetch, og den identiske Stop-knap venter tre sekunder — værktøjets længde — og runnet »annulleres«, efter det arbejde, den var ved at annullere, allerede er færdigt. Annullering, der ikke er ført hele vejen ned, er en spinner, der siger det rigtige ord.
Tracen, og hvorfor den ikke er en log
Link til afsnittet: Tracen, og hvorfor den ikke er en logHarness udsender én linje pr. event, og vokabularet er lille nok til at lære udenad: 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}Tre egenskaber gør dette til en trace snarere end logging. Hver linje bærer run id, så et run, der spænder over tre processer og to dage, er én query. Hver turn-linje bærer sine egne token counts og den løbende omkostning, så »hvorfor kostede dette run fyrre dollars« kan besvares bagefter i stedet for kun at være reproducerbart i teorien. Og run_stopped bærer årsagen, som er feltet, der forvandler en supportticket til et énlinjes svar: en agent, der stoppede ved budgettet, og en agent, der crashede, ser identiske ud udefra og kræver modsatte svar.
Latensens aritmetik
Link til afsnittet: Latensens aritmetikKapitel 13 målte time to first token på hardware, du ejer. Kapitel 14 målte det gennem en socket. En agent multiplicerer det, og multiplikatoren er et tal, ingen har valgt:
Den samme tretursopgave, hvor kun providerens latency ændres:
| provider latency pr. tur | wall clock, 3 ture |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2.413 ms |
Selve harness bidrager med femten millisekunder til et treturs-run. Alt andet er ganget med et tal, du ikke kontrollerer — sat inde i en serving scheduler, der batcher din request med fremmedes requests6 — og vælges af modellen. Det er derfor streaming fra Kapitel 14 betyder mere her end i en chat og hjælper mindre: du kan streame den sidste tur, og de fire ture før den er stilhed, medmindre harness udsender fremdrift. Det er også hele argumentet for tool_progress-eventen ovenfor — i en agent er den ærlige feedback-enhed ikke token, det er skridtet.
Samme harness, en rigtig model bag porten
Link til afsnittet: Samme harness, en rigtig model bag portenAlt ovenfor kørte mod en scripted provider, hvilket beviser harness og intet beviser om modeller. Så ændr én linje — seamet fra Kapitel 14, LLM_BASE_URL — og peg den identiske kode mod en lokal Qwen2.5-0.5B-Instruct med de samme fire værktøjer. Seks opgaver over de samme tre filer:
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,908msTre fund, og det tredje er grunden til, at dette afsnit findes.
Hver eneste opgave sluttede på præcis to ture. Turgrænsen blev aldrig aktiveret, budgettet blev aldrig aktiveret, og loopets eneste udgang var, at modellen producerede prosa. En model med en halv milliard parametre itererer ikke; den svarer på sit andet åndedrag, uanset om den har det, den behøver. Antallet af ture er en egenskab ved modellen, ikke ved dit loop.
Den gennemsnitlige tur tog 6.908 millisekunder, så latenstabellen ovenfor er ikke et legetøj: ved denne størrelse er et hypotetisk otte-turs-run næsten et minuts wall clock uden noget på skærmen.
Og svarene er forkerte. Den største fil er errors.log; modellen oplistede filerne, læste dem aldrig og navngav alligevel en. Den første opgave gættede et filnavn, fik at vide at det ikke fandtes, og konkluderede. Harness kørte fejlfrit i alle seks runs. Et harness gør en agent styrbar, ikke korrekt — Kapitel 29 er, hvordan du finder ud af hvilken, og Kapitel 30 er, hvad det koster, når ingen gjorde det.
Subagents, navngivet her og opkrævet senere
Link til afsnittet: Subagents, navngivet her og opkrævet senereÉt værktøj i kataloget kan have et andet run bag sig. Interfacet er Kapitel 18's — et schema og et endpoint — og en hel agent passer bag det, fordi interfacet er smalt:
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";
},
};Tre ting er allerede rigtige i de ti linjer, og alle tre er konsekvenser af beslutningerne ovenfor: barnet har sin egen window, så forælderens transcript modtager et summary i stedet for alt det, barnet læste; det har sine egne limits, så et runaway-barn ikke kan bruge forælderens budget; og det arver signalet, så ét Stop annullerer træet. Hvorfor en ren window er pointen snarere end en sideeffekt, er Kapitel 24; de fem orchestration patterns — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — og handoff er Kapitel 25.
Hvor frameworks er, og hvorfor dette kursus ikke brugte et
Link til afsnittet: Hvor frameworks er, og hvorfor dette kursus ikke brugte etIntet ovenfor bør læses som et argument mod libraries. Målt den 7. september 2026 for måneden, der sluttede 29. august:7
| package | downloads den måned | hvad den giver dig |
|---|---|---|
ai (Vercel AI SDK) | 89.385.860 | ToolLoopAgent, stopWhen, tool approval, step hooks |
@anthropic-ai/claude-agent-sdk | 41.558.352 | Claude Code harness som library: loop, sessions, hooks, permissions, subagents8 |
@langchain/langgraph | 12.812.815 | loopet som en eksplicit state graph |
langchain | 11.359.058 | chains, agents, integrations |
@openai/agents | 6.093.155 | agents, handoffs, guardrails |
@mastra/core | 5.914.502 | agents, workflows, memory |
Grunden til, at dette kursus skriver loopet i hånden i stedet for at undervise i et af dem, erklæres snarere end antydes, og den kan måles. I de tolv måneder frem til 7. september 2026 udgav ai 945 versioner og flyttede fra major 5 til major 7, og dens agent-klasse eksporteres stadig som Experimental_Agent; langchain udgav 132 versioner i samme vindue; @openai/agents udgav 83 og er stadig på 0.x femten måneder efter sin første release.7 Et kapitel skrevet mod en hvilken som helst af de API'er er forældet inden for en sæson, og dette udgives på treogtredive sprog, så hver ny udgave koster hele oversættelsen. Det, der ligger under dem alle, flytter sig ikke: et loop, en stopping rule, et katalog, en executor, noget state.
Og referenceimplementeringen er enig med dette kapitel om den del, der betyder noget. I ai version 7.0.93 er loopets exit ikke et tal — det er stopWhen, en liste af predicates, hvor en step count blot er én:3
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>; // exported as stepCountIsStopping er plural i den mest brugte implementering af dette loop af samme grund, som den er plural i de hundrede og seksoghalvfems linjer ovenfor.
Hvor det går hen nu
Link til afsnittet: Hvor det går hen nuDu har nu et harness: et loop, et katalog, en executor, fem veje ud, et persisted run, et signal der når værktøjerne, og en trace med run id på hver linje. Kapitel 24, 25, 29 og 30 bygger på denne fil, og 26 til 28 på det, den kan nå.
Den har ét problem tilbage, og målingerne ovenfor har peget på det hele vejen. Se på runaway-tabellen én gang til: 3.431 input tokens ved otte ture, 337.299 ved hundrede. Se på det fungerende run: 204, 269, 342. Hver tur sender hele transcriptet igen, så en agents kontekst fyldes med dens egen historik — og modellen er dårligere til at bruge den fjerne ende af en lang window end den nære ende, hvilket er grunden til, at en god agent ved tur fem er en forvirret agent ved tur fyrre.
En turgrænse løser ikke det. Den stopper dig bare fra at betale for at se det ske. Det, der løser det, er at beslutte i hver eneste tur, hvilke tokens der fortjener window: hvad der skal komprimeres, hvad der skal flyttes ud til en note, agent kan hente, hvad der skal gives til en subagent med en ren window, og hvilke værktøjsdefinitioner der er deres permanente skat værd. Kapitel 24 måler, hvor window faktisk går hen — og overraskelsen er, at det ikke er samtalen.
Kilder og metode
Link til afsnittet: Kilder og metodeHvert tal i dette kapitel kom fra de to servere beskrevet ovenfor, på Node 22 over en loopback interface: en scripted provider, der tæller tokens med o200k_base-encoding, og Qwen/Qwen2.5-0.5B-Instruct bag et endpoint af samme form, greedy decoding, på CPU. Omkostninger beregnes fra målte token counts til de satser, Kapitel 16 aflæste den 6. september 2026 — $2.00 pr. million input tokens og $12.00 pr. million output — og ingen request i dette kapitel gik til et betalt endpoint. Den lokale models svar er en lille models svar; læs dem som evidens om loopet, der er identisk uanset hvad, og ikke som en benchmark af, hvad nuværende modeller gør.
Referencer
Link til afsnittet: Referencer-
Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. og Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). Sammenfletningen af reasoning traces og actions, som loopet implementerer, og kilden til observationen om, at acting lader en model »handle exceptions« — hvilket er præcis det, tabellen med værktøjsfejl ovenfor måler. ↩
-
Sumers, T. R., Yao, S., Narasimhan, K. og Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). Den formelle behandling af det, loopet ovenfor gør uformelt: modulære memory-komponenter, et struktureret action space, der spænder over intern memory og eksterne environments, og »a generalized decision-making process to choose actions«. Læs den for det vokabular, industribegrebet mangler — især adskillelsen mellem working, episodic, semantic og procedural memory, hvis praktiske skygge er Kapitel 24's tre-store-tabel. ↩
-
ai(Vercel AI SDK) version 7.0.93, udgivet 4. september 2026; type-deklarationer læst fracdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.tsden 7. september 2026. Filen på 397 KB indeholder nul forekomster af strengenharness. Agent-klassen erdeclare class ToolLoopAgent, eksporteret både somToolLoopAgentog somExperimental_Agent;declare function isStepCount(stepCount: number)— eksporteret somstepCountIs— citeres ordret ovenfor;type StopConditionvises uden sin anden type parameter (RUNTIME_CONTEXT extends Context = Context), hvilket er den eneste udeladelse i excerptet, ligesom formen påstopWhen?: Arrayable<StopCondition<...>>pågenerateTextogstreamText. Samme fil deklarerertoolApproval,ToolApprovalStatus,prepareStepogrepairToolCall, hvilket vil sige, at referenceimplementeringen uafhængigt er nået frem til approval gates, per-step preparation og error repair. ↩ ↩2 -
Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. og Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). Abstractet kalder artefaktet et »evaluation framework« med 2.294 problemer og bruger aldrig ordet »harness«; projektets egen README (
github.com/SWE-bench/SWE-bench, læst 7. september 2026) bruger det fem gange, altid som »evaluation harness«, og entry point erpython -m swebench.harness.run_evaluation. Det er ordets anden betydning: et stillads, der holder agent stille og scorer den, ikke loopet der kører den. ↩ -
Anthropic, Building effective agents, 19. december 2024,
anthropic.com/engineering/building-effective-agents, læst 7. september 2026. Den augmented model som building block, agent som en LLM, der »using tools based on environmental feedback in a loop«, og anbefalingen af stopping conditions »such as a maximum number of iterations« for at bevare kontrol. Kapitel 22 citerer definitionen i fuld længde. ↩ -
Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. og Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). Det andet loop — serving scheduleren, der batcher din request med fremmedes requests og administrerer KV cache fra Kapitel 13. Det er værd at vide, at den findes, netop fordi den ikke er din: den latency, dit harness multiplicerer, sættes inde i den, og intet arbejde på dit loop flytter den. ↩
-
npm registry-downloadtal,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, et eksplicit vindue snarere end det rullendelast-month-vindue, og release-historikker fraregistry.npmjs.org/<package>; begge forespurgt 7. september 2026. Release counts er antallet af versioner udgivet i de tolv måneder frem til den dato, canary builds inkluderet:ai945 (seneste 7.0.93 den 2026-09-04, med major versions 5, 6 og 7 alle inden for vinduet),langchain132 (seneste 1.5.10 den 2026-08-20),@openai/agents83 (seneste 0.17.0 den 2026-08-19, først udgivet 2025-06-03). ↩ ↩2 -
Claude Agent SDK (
@anthropic-ai/claude-agent-sdk) er Claude Code harness pakket som et library — agent loop, indbyggede fil- og shell-værktøjer, context management, sessions, hooks, permissions og subagents — dokumenteret påcode.claude.com/docs/en/agent-sdk. Det er det nærmeste på en publiceret gennemgang af hver mekanisme, dette kapitel bygger i hånden, og det er værd at læse ved siden af din egen implementering for de dele, det navngiver, som dette kapitel kun peger mod. ↩