Naar inhoud springen
23/30Hoofdstuk 23 van 30

Bouw een agent harness: de loop en zijn vijf uitwegen

Een loop van vijftien regels die meteen werkt, daarna expres zeven keer breekt — beginnend met een runaway die 77× meer kost.

Op deze pagina

Begin met het eerlijke deel, want niemand anders zal het zeggen: ‘harness’ is jargon, geen standaard. Er is geen specificatie, geen commissie, geen referentiedefinitie. De vier papers die dit hoofdstuk citeert — ReAct,1 CoALA,2 SWE-bench en vLLM — gebruiken het woord geen enkele keer in hun abstracts. De meest gedownloade implementatie van dit ding, Vercels ai package met 89,4 miljoen downloads per maand, gebruikt het ook niet: de string harness komt nul keer voor in de 397 KB aan type declarations die versie 7.0.93 meelevert.3 De ene plek waar het woord wel dragend is, betekent iets heel anders. SWE-bench zegt vijf keer ‘harness’ in zijn README, altijd als evaluation harness — het gecontaineriseerde steigerwerk dat een patch toepast en de tests draait — en de Python-module heet letterlijk swebench.harness.run_evaluation.4

Dus twee verschillende dingen delen een naam. Een evaluation harness houdt de agent stil en geeft hem een score. Een agent harness is het programma dat de agent uitvoert: het roept het model aan, voert uit wat het model vraagt, beslist wanneer het moet stoppen en houdt tussendoor de state vast. Dit hoofdstuk bouwt de tweede, in minder dan tweehonderd regels TypeScript, zonder framework.

De loop zelf telt vijftien regels en werkt bij de eerste poging. Alles daarna is een manier om eruit te stappen.

Details tonen

Wat dit hoofdstuk nodig heeft uit de vorige hoofdstukken.

  • Hoofdstuk 14 voor de client: deadlines, status-triage, annulering, idempotency keys en de mock-providertechniek die hier opnieuw wordt gebruikt.
  • Hoofdstuk 16 voor de rekenkunde: input tokens groeien met het kwadraat van het gesprek, en de tarieven hieronder zijn de tarieven die daar op 6 september 2026 zijn uitgelezen.
  • Hoofdstuk 18 voor de toolcatalogus: een schema dat het model ziet, een endpoint dat het nooit ziet, en de regel dat errors context zijn in plaats van exceptions.
  • Hoofdstuk 22 voor de loop die deze erft, en voor de twee gepubliceerde definities van ‘agent’ die het niet met elkaar eens zijn.

Geen tensors hier. Dit is de tweede dependency-hub van de cursus: hoofdstukken 24, 25, 29 en 30 draaien op het bestand hieronder, en 26 tot 28 bouwen op wat het kan bereiken.

Hoofdstuk 14 kon niet tegen een echte provider worden geschreven, omdat je er niet op een gekozen moment om een 429 kunt vragen. Dit hoofdstuk heeft hetzelfde probleem in een andere vorm: je kunt een echt model niet vragen om weg te lopen, of om twee keer achter elkaar exact dezelfde tool aan te vragen, op commando en reproduceerbaar.

Dus het eerste programma is een scripted provider: een endpoint met de vorm van een chat-completions-API waarvan het antwoord een functie is van de beurtindex en van wat de tools tot dan toe hebben teruggegeven. Het telt tokens met een echte byte-pair encoder, zodat het geld hieronder rekenkunde is en geen decoratie.

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);

Twee regels dragen het ontwerp. De beurtindex wordt afgeleid uit het gesprek, niet in een variabele bewaard, zodat de provider stateless is en een run kan worden afgebroken en ertegen kan worden hervat. En recover leest de toolresultaten voordat het beslist: een scripted model dat zijn eigen transcript leest, is het minimum dat nodig is om te meten of de harness het iets heeft gegeven dat het lezen waard is.

De catalogus is die van hoofdstuk 18, vier tools over drie bestanden: list_files, read_file, delete_file — gemarkeerd als needsApproval — en scan_archive, die expres traag is.

Dit is het hele idee, vóór alle onderdelen die het overleefbaar maken.

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 });
  }
}

Richt hem op de scripted provider en hij doet precies wat hij lijkt te doen:

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

Drie beurten, twee tooluitvoeringen, een kwart Amerikaanse cent. Let op de laatste regel: 204, 269, 342. Elke beurt verstuurt alles ervoor opnieuw, de kwadratische rekening uit hoofdstuk 16 die verschijnt op een plek waar niemand iets heeft getypt. De rest van dit hoofdstuk gaat over wat er gebeurt wanneer die regel niet stopt met groeien.

Richt dezelfde loop op het runaway script — een model dat elke afzonderlijke beurt om een tool vraagt en nooit proza uitstoot — en de gemarkeerde return vuurt nooit. Er is geen andere uitgang. Het programma draait totdat het proces sterft of de creditcard dat doet.

De fix is één regel, het is de eerste controle die de literatuur aanbeveelt,5 en uiteindelijk schrijft iedereen hem. Wat bijna niemand doet, is meten wat hij waard is:

beurtlimietmodel callsinput tokenskosten
883.431$0.009070
202016.259$0.038038
505088.649$0.191098
100100337.299$0.702198

Lees de laatste twee rijen samen. De limiet verdubbelen van 50 naar 100 verdubbelde de kosten niet; hij vermenigvuldigde ze met 3,7. Input tokens gingen van 88.649 naar 337.299, een factor 3,8, omdat beurt nn elke vorige beurt met zich meedraagt en het totaal Θ(n2)\Theta(n^2) is. Een beurtlimiet is geen lineaire knop. Het is een knop op de vierkantswortel van je worstcase, en daarom is hem verhogen van 20 naar 100 ‘voor de zekerheid’ een beslissing die je moet prijzen voordat je hem neemt.

Breuk twee: een limiet op beurten is geen limiet op geld

Link naar de sectie: Breuk twee: een limiet op beurten is geen limiet op geld

Het probleem met een beurtlimiet is dat een beurt geen vaste prijs heeft. Twintig beurten over een kort transcript kosten hierboven $0.038. Twintig beurten met een catalogus van 200 tools, een set opgehaalde documenten en veertig berichten geschiedenis kosten honderden keren zoveel, en de limiet weet dat niet. Wat de operator wil begrenzen, is de rekening.

Dus de loop telt geld, met hoofdstuk 16s computeCost tegen de tarieven die daar zijn uitgelezen — $2.00 per miljoen input tokens en $12.00 per miljoen output, voor het model dat in deze cursus steeds wordt geprijsd:

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);

Hetzelfde runaway-script, helemaal geen beurtlimiet, drie budgetten:

budgetbereikte beurtenwerkelijk uitgegeven
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

Twee dingen verdienen een naam. Ten eerste koopt het budget telkens een ander aantal beurten, en dat is het punt: het begrenst datgene waar de operator om geeft, en laat het aantal beurten vallen waar het transcript het neerzet. Ten tweede: elke rij schiet door. Het budget was $0.010 en er werd $0.010780 uitgegeven, omdat de check vóór een beurt draait en de prijs van een beurt pas bekend is wanneer die voorbij is. Je kunt uitgaven niet exact begrenzen; je kunt ze begrenzen tot binnen de kosten van één beurt. Zeg dat in de interface in plaats van te doen alsof, en zet de check vóór de call zodat de overschrijding één beurt is en geen twee.

Vijf manieren om de loop te verlaten, niet één

Link naar de sectie: Vijf manieren om de loop te verlaten, niet één

Inmiddels heeft de loop drie uitgangen, en de vorm van de rest van het hoofdstuk is zichtbaar. Een productierun eindigt op precies één van vijf manieren, en dat zijn geen variaties op elkaar:

hoe het eindigtwie beslistewat de caller moet doen
het model stopte met vragenhet modellees het antwoord
beurtlimietjij, voorafverhoog de limiet, of accepteer een gedeeltelijk resultaat
budget uitgeputjij, voorafkeur meer geld goed, of accepteer een gedeeltelijk resultaat
een error die je niet opnieuw kunt proberende provider of een toolfix de deployment; hoofdstuk 14s triage beslist
een mens greep ineen persoonwacht op een oordeel, en hervat daarna

Deze samenvoegen tot één boolean is de meest voorkomende ontwerpfout in dit bestand, en hij is duur op een specifieke manier: drie van de vijf zijn hervatbaar en twee niet. Een agent die zijn beurtlimiet heeft geraakt, heeft een geldig transcript, een echt gedeeltelijk resultaat en een volgende stap; een agent die een 401 kreeg, heeft geen van die dingen. Dus de harness legt de reden vast als data:

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 };

Hoofdstuk 18 eindigde met een claim zonder getal: geef de error van een tool terug aan het model als toolresultaat in plaats van hem te raisen, en het model herstelt zichzelf meestal. Hier is het getal.

Eén fout, drie policies. Het scripted model raadt een bestand dat niet bestaat; de tool gooit no such file: timeout.log. Call list_files to see what exists.

wat de harness met de error doetbeurtentoolrunskostenwat de gebruiker kreeg
gooit hem uit de loop11$0.000756een stacktrace
retourneert Error: the tool failed.21$0.001462‘Ik kon het bestand niet lezen, dus ik weet het niet.’
retourneert wat er echt gebeurde43$0.003550‘errors.log vermeldt een timeout.’

De derde rij kost 4,7 keer zoveel als de eerste en is de enige die de vraag beantwoordt. En de tweede rij is de interessante, omdat dit is wat de meeste codebases in de praktijk doen: de error werd opgevangen, de loop overleefde, het model kreeg te horen dat er iets faalde en niet wat, en het gaf beleefd op. Het verschil tussen rij twee en drie is geen error handling. Het is een zin die voor een lezer is geschreven.

De harness behandelt een gegooide tool daarom als data, en maakt de formulering tot policy:

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);   
}

Hoofdstuk 18 waarschuwde ook voor de andere kant, en ook die heeft een prijs. Richt de loop op een tool die faalt om een reden die geen enkel bericht kan fixen — een read die het proces niet mag uitvoeren — en het model probeert het eindeloos opnieuw:

TEXT
read a file the process may not open   turns=12  toolruns=11  in=7,079  cost=$0.018622
                                      status=max_turns_exceeded   answer=""

Elf identieke uitvoeringen van een call die niet kan slagen, 5,2 keer de kosten van de run die herstelde van een fixbare fout, en niets aan het einde. Errors zijn context; een permanente error is context die de rest van de run vergiftigt. Het onderscheid is hoofdstuk 14s status-triage één laag hoger: een error waar het model iets mee kan, gaat terug het transcript in, en een error waar het niets mee kan, moet de run stoppen met een reden. De beurtlimiet is wat vandaag tussen jou en het tweede geval staat, en dat is een ondergrens, geen fix.

Nu de fout waarvan de meeste mensen aannemen dat hij niet kan gebeuren. Modellen herhalen zichzelf. Laat eender welke loop lang genoeg draaien en je ziet dezelfde tool met dezelfde argumenten in twee opeenvolgende beurten.

Gemeten tegen de baseline van dezelfde taak zonder herhaling:

beurtentoolrunskosten
de taak, geen herhaling21$0.001396
dezelfde taak, één call herhaald32$0.002446
herhaald, met een resultaatcache op read-only tools31$0.002446

De dubbele call kostte $0.001050 extra, een stijging van 75%, en dit is het deel dat mensen verrast: caching van het resultaat haalde niets daarvan terug. Deduplicatie bespaarde de tooluitvoering en niet de beurt, omdat tegen de tijd dat je code de herhaling ziet, het model al is betaald voor het vragen. De besparing is echt wanneer de tool traag is, rate-limited is of per call wordt afgerekend — en ze is nul op de regelpost die groeide.

Er is een ergere versie. Pas dezelfde cache toe op een tool die schrijft, en de tweede call gebeurt stilletjes niet:

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"]

Welke daarvan is correct? Geen van beide, kenbaar. Het protocol zegt dat dit twee calls zijn: ze dragen twee verschillende tool_call_id waarden. De argumenten zeggen dat ze één kunnen zijn. Een harness die beslist door argumentstrings te vergelijken, zal op een dag de tweede van twee identieke, bedoelde afschrijvingen inslikken — en hoofdstuk 14 noemde al het enige mechanisme dat dit eerlijk oplost: een idempotency key die per logische operatie wordt gegenereerd door de laag die weet wat de operatie is. Totdat de tool er een draagt, is de verdedigbare default de read-only gate hierboven: cache reads, voer writes uit, en laat de eigen idempotency van de write de rest afhandelen.

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;
}

Het destructive script lijst de bestanden op en vraagt daarna om er een te verwijderen die de taak nooit heeft genoemd. Niets in de loop tot nu toe zou dat stoppen.

Een tool gemarkeerd als needsApproval faalt niet en gaat niet door. Hij stopt de run en geeft controle terug, met alles wat een persoon nodig heeft om te beslissen:

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

Dat is het hele mechanisme, en de reden dat het een return is in plaats van een callback is de volgende sectie: tussen de stop en het oordeel bestaat het proces misschien niet meer.

Maar eerst de meting die niemand verwacht. Een afwijzing is niet de afwezigheid van een resultaat — het transcript heeft een slot met sleutel tool_call_id en daar moet iets in. Draai dezelfde afwijzing twee keer, en verander alleen wat dat iets zegt:

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

In geen van beide runs werd iets verwijderd, en in de tweede krijgt de gebruiker te horen dat het wel is gebeurd. Het permissiesysteem werkte perfect; het rapport is een leugen. Het is hetzelfde mechanisme als de tool-error-tabel, maar dan op een plek waar het veel meer uitmaakt — een mens zei nee, de actie werd correct geblokkeerd, en de samenvatting van de agent spreekt de werkelijkheid tegen omdat de weigering nooit werd opgeschreven waar het model leest. De regel die hieruit volgt is kort: wat je code ook beslist over een tool call, schrijf de beslissing in woorden in het transcript. Hoofdstuk 30 komt hierop terug vanaf de security-kant, waar dit het verschil is tussen een audit trail en fictie.

Een approval duurt minuten of uren. Een deploy duurt seconden. Als de run in een lokale variabele binnen een HTTP-request leeft, is elke restart een verloren run en elke approval een race.

Dus de run is geen closure. Het is een gewoon serialiseerbaar object — berichten, beurtentelling, kosten, status, onderbreking, de lijst met goedgekeurde call ids — en de loop is een pure functie eroverheen. Die ene beperking maakt persistence een zorg van één regel:

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"));

De correctheidsvraag is niet opslaan. Het is wat er gebeurt op de weg terug naar binnen, en het naïeve antwoord rekent je dubbel af. Als het proces stierf nadat het model om een tool vroeg maar voordat het resultaat werd geschreven, betaalt een resume die begint met het model opnieuw aanroepen voor een beurt die hij al heeft — en als hij begint met de tools opnieuw draaien, voert hij een write twee keer uit.

De fix is de loop te laten beginnen met het transcript vragen wat nog openstaat:

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));    
}

Elke iteratie leegt eerst pending en vraagt het model alleen wanneer er niets openstaat. Resume wordt hetzelfde codepad als het normale pad, en approval ook — een goedgekeurde call is simpelweg een pending call die nu mag draaien. Kill het proces midden in een taak en start het opnieuw:

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

Twee tooluitvoeringen over twee processen voor een taak die er twee nodig heeft, en de uiteindelijke kosten zijn identiek aan de run die nooit crashte. Kosten stapelen zich over de restart op omdat ze in de state zaten, niet in een variabele.

scan_archive duurt hier drie seconden en staat voor de tool die in productie drie minuten duurt. Terwijl hij draait, ontbreken twee dingen: de gebruiker heeft geen idee dat er iets gebeurt, en de Stop-knop doet niets.

Beide hebben dezelfde fix, en het is hoofdstuk 14s AbortSignal één niveau dieper. Het signal is niet alleen voor de fetch — het wordt in de tool doorgegeven, en een goed geschreven tool respecteert het:

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"

Twee milliseconden van klik tot stop, omdat de sleep in de tool luistert naar hetzelfde signal als de fetch. Geef het alleen door aan fetch en dezelfde Stop-knop wacht drie seconden — de lengte van de tool — en de run ‘annuleert’ nadat het werk dat hij annuleerde al klaar is. Cancellation die niet helemaal naar beneden is geplumbd, is een spinner die het juiste woord zegt.

De harness emit één regel per event, en de woordenschat is klein genoeg om uit je hoofd te leren: 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}

Drie eigenschappen maken dit een trace in plaats van logging. Elke regel draagt de run id, zodat een run die drie processen en twee dagen overspant één query is. Elke turn regel draagt zijn eigen tokenaantallen en de lopende kosten, zodat ‘waarom kostte deze run veertig dollar’ achteraf beantwoordbaar is in plaats van alleen in theorie reproduceerbaar. En run_stopped draagt de reden, het veld dat een supportticket verandert in een antwoord van één regel: een agent die stopte bij het budget en een agent die crashte zien er van buiten identiek uit en hebben tegengestelde reacties nodig.

Hoofdstuk 13 mat time to first token op hardware die je bezit. Hoofdstuk 14 mat het door een socket. Een agent vermenigvuldigt het, en de vermenigvuldiger is een getal dat niemand heeft gekozen:

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

Dezelfde taak van drie beurten, met alleen de latency van de provider veranderd:

provider latency per beurtwandklok, 3 beurten
0 ms15 ms
200 ms615 ms
800 ms2.413 ms

De harness zelf draagt vijftien milliseconden bij aan een run van drie beurten. Al het andere is NN vermenigvuldigd met een getal dat je niet controleert — ingesteld binnen een serving scheduler die jouw request batched met requests van vreemden6 — en NN wordt gekozen door het model. Daarom is de streaming uit hoofdstuk 14 hier belangrijker dan in een chat en helpt die minder: je kunt de laatste beurt streamen, en de vier beurten ervoor zijn stilte tenzij de harness voortgang emit. Het is ook het hele argument voor het tool_progress event hierboven — in een agent is de eerlijke feedbackeenheid niet de token, maar de stap.

Dezelfde harness, een echt model achter de poort

Link naar de sectie: Dezelfde harness, een echt model achter de poort

Alles hierboven draaide tegen een scripted provider, wat de harness bewijst en niets over modellen bewijst. Verander dus één regel — de seam uit hoofdstuk 14, LLM_BASE_URL — en richt dezelfde code op een lokale Qwen2.5-0.5B-Instruct met dezelfde vier tools. Zes taken over dezelfde drie bestanden:

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

Drie bevindingen, en de derde is de reden dat deze sectie bestaat.

Elke afzonderlijke taak eindigde in precies twee beurten. De beurtlimiet vuurde nooit, het budget vuurde nooit, en de enige uitgang van de loop was dat het model proza produceerde. Een model met een half miljard parameters itereert niet; het antwoordt op zijn tweede adem, of het nu heeft wat het nodig heeft of niet. Het aantal beurten is een eigenschap van het model, niet van je loop.

De gemiddelde beurt duurde 6.908 milliseconden, dus de latency-tabel hierboven is geen speelgoed: op dit formaat is een hypothetische run van acht beurten bijna een minuut wandklok zonder iets op het scherm.

En de antwoorden zijn fout. Het grootste bestand is errors.log; het model lijstte de bestanden op, las ze nooit en noemde er toch één. De eerste taak raadde een bestandsnaam, kreeg te horen dat die niet bestond, en concludeerde. De harness voerde alle zes runs foutloos uit. Een harness maakt een agent bestuurbaar, niet correct — hoofdstuk 29 is hoe je ontdekt welke van de twee, en hoofdstuk 30 is wat het kost wanneer niemand dat deed.

Eén tool in de catalogus kan een andere run achter zich hebben. De interface is die van hoofdstuk 18 — een schema en een endpoint — en een volledige agent past erachter omdat die interface smal is:

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";
  },
};

Drie dingen zijn al goed in die tien regels en alle drie zijn gevolgen van beslissingen die hierboven zijn genomen: het kind heeft zijn eigen window, zodat het transcript van de ouder een samenvatting ontvangt in plaats van alles wat het kind las; het heeft zijn eigen limieten, zodat een runaway-kind het budget van de ouder niet kan uitgeven; en het erft het signal, zodat één Stop de boom annuleert. Waarom een schoon window het punt is in plaats van een bijeffect, staat in hoofdstuk 24; de vijf orchestration patterns — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — en de handoff staan in hoofdstuk 25.

Waar de frameworks zijn, en waarom deze cursus er geen gebruikte

Link naar de sectie: Waar de frameworks zijn, en waarom deze cursus er geen gebruikte

Niets hierboven moet worden gelezen als een argument tegen libraries. Gemeten op 7 september 2026, voor de maand eindigend op 29 augustus:7

packagedownloads die maandwat het je geeft
ai (Vercel AI SDK)89.385.860ToolLoopAgent, stopWhen, tool approval, step hooks
@anthropic-ai/claude-agent-sdk41.558.352de Claude Code harness als library: loop, sessions, hooks, permissions, subagents8
@langchain/langgraph12.812.815de loop als expliciete state graph
langchain11.359.058chains, agents, integrations
@openai/agents6.093.155agents, handoffs, guardrails
@mastra/core5.914.502agents, workflows, memory

De reden dat deze cursus de loop met de hand schrijft in plaats van er één te onderwijzen, wordt uitgesproken in plaats van geïmpliceerd, en is meetbaar. In de twaalf maanden tot 7 september 2026 publiceerde ai 945 versies en ging van major 5 naar major 7, en zijn agent class wordt nog steeds geëxporteerd als Experimental_Agent; langchain publiceerde 132 versies in dezelfde periode; @openai/agents publiceerde er 83 en zit nog steeds op 0.x, vijftien maanden na de eerste release.7 Een hoofdstuk dat tegen een van die API’s wordt geschreven, is binnen een seizoen verouderd, en dit hoofdstuk verschijnt in drieëndertig talen, dus elke heruitgave kost de hele vertaling. Wat eronder ligt, beweegt niet: een loop, een stopregel, een catalogus, een executor, wat state.

En de referentie-implementatie is het met dit hoofdstuk eens over het deel dat telt. In ai versie 7.0.93 is de uitgang van de loop geen getal — het is stopWhen, een lijst met predicates, waarvan een stappentelling er slechts één is: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

Stoppen is meervoud in de meest gebruikte implementatie van deze loop, om dezelfde reden dat het meervoud is in de honderdzesennegentig regels hierboven.

Je hebt nu een harness: een loop, een catalogus, een executor, vijf uitwegen, een gepersiste run, een signal dat de tools bereikt, en een trace met een run id op elke regel. Hoofdstukken 24, 25, 29 en 30 bouwen op dit bestand, en 26 tot 28 op wat het kan bereiken.

Er blijft één probleem over, en de metingen hierboven wijzen er de hele tijd al naar. Kijk nog één keer naar de runaway-tabel: 3.431 input tokens bij acht beurten, 337.299 bij honderd. Kijk naar de werkende run: 204, 269, 342. Elke beurt verstuurt het hele transcript opnieuw, dus de context van een agent vult zich met zijn eigen geschiedenis — en het model is slechter in het gebruiken van het verre einde van een lang window dan van het nabije einde, daarom is een goede agent bij beurt vijf een verwarde bij beurt veertig.

Een beurtlimiet fixt dat niet. Hij voorkomt alleen dat je betaalt om het te zien gebeuren. Wat het fixt, is in elke afzonderlijke beurt beslissen welke tokens het window verdienen: wat je compacter maakt, wat je verplaatst naar een notitie die de agent kan ophalen, wat je overdraagt aan een subagent met een schoon window, en welke tooldefinities hun permanente belasting waard zijn. Hoofdstuk 24 meet waar het window werkelijk naartoe gaat — en de verrassing is dat het niet het gesprek is.


Elk getal in dit hoofdstuk kwam uit de twee servers die hierboven zijn beschreven, op Node 22 over een loopback-interface: een scripted provider die tokens telt met de o200k_base encoding, en Qwen/Qwen2.5-0.5B-Instruct achter een endpoint met dezelfde vorm, greedy decoding, op CPU. Kosten worden berekend uit gemeten tokenaantallen tegen de tarieven die hoofdstuk 16 op 6 september 2026 uitlas — $2.00 per miljoen input tokens en $12.00 per miljoen output — en geen enkel request in dit hoofdstuk ging naar een betaald endpoint. De antwoorden van het lokale model zijn antwoorden van een klein model; lees ze als bewijs over de loop, die in beide gevallen identiek is, en niet als benchmark van wat huidige modellen doen.

  1. Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. en Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). De verweving van reasoning traces en actions die de loop implementeert, en de bron van de observatie dat acting een model ‘handle exceptions’ laat doen — precies wat de tool-error-tabel hierboven meet.

  2. Sumers, T. R., Yao, S., Narasimhan, K. en Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). De formele behandeling van wat de loop hierboven informeel doet: modulaire memory components, een structured action space die interne memory en externe omgevingen overspant, en ‘a generalized decision-making process to choose actions’. Lees het voor de woordenschat die de industrie-term mist — vooral de scheiding tussen working, episodic, semantic en procedural memory, waarvan de praktische schaduw de tabel met drie stores in hoofdstuk 24 is.

  3. ai (Vercel AI SDK) versie 7.0.93, gepubliceerd op 4 september 2026; type declarations gelezen uit cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts op 7 september 2026. Het bestand van 397 KB bevat nul voorkomens van de string harness. De agent class is declare class ToolLoopAgent, geëxporteerd zowel als ToolLoopAgent als als Experimental_Agent; declare function isStepCount(stepCount: number) — geëxporteerd als stepCountIs — wordt hierboven letterlijk geciteerd; type StopCondition wordt getoond zonder zijn tweede type parameter (RUNTIME_CONTEXT extends Context = Context), wat de enige weglating in het fragment is, net als de vorm van stopWhen?: Arrayable<StopCondition<...>> op generateText en streamText. Hetzelfde bestand declareert toolApproval, ToolApprovalStatus, prepareStep en repairToolCall, wat wil zeggen dat de referentie-implementatie onafhankelijk is uitgekomen bij approval gates, per-step preparation en error repair. 2

  4. Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. en Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). De abstract noemt het artefact een ‘evaluation framework’ van 2.294 problemen en gebruikt het woord ‘harness’ nooit; de eigen README van het project (github.com/SWE-bench/SWE-bench, gelezen op 7 september 2026) gebruikt het vijf keer, altijd als ‘evaluation harness’, en het entry point is python -m swebench.harness.run_evaluation. Dat is de andere betekenis van het woord: een steigerwerk dat de agent stilhoudt en scoort, niet de loop die hem uitvoert.

  5. Anthropic, Building effective agents, 19 december 2024, anthropic.com/engineering/building-effective-agents, gelezen op 7 september 2026. Het augmented model als bouwsteen, de agent als een LLM ‘using tools based on environmental feedback in a loop’, en de aanbeveling van stopping conditions ‘such as a maximum number of iterations’ om controle te behouden. Hoofdstuk 22 citeert de definitie volledig.

  6. Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. en Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). De andere loop — de serving scheduler die jouw request batched met requests van vreemden en de KV cache uit hoofdstuk 13 beheert. Het is de moeite waard te weten dat hij bestaat juist omdat hij niet van jou is: de latency die je harness vermenigvuldigt wordt erin ingesteld, en geen enkele hoeveelheid werk aan je loop verplaatst die.

  7. Downloadtellingen uit het npm-register, api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, een expliciet window in plaats van het rollende last-month window, en releasegeschiedenissen uit registry.npmjs.org/<package>; beide bevraagd op 7 september 2026. Releasetellingen zijn het aantal versies dat in de twaalf maanden tot die datum is gepubliceerd, inclusief canary builds: ai 945 (latest 7.0.93 op 2026-09-04, met major versions 5, 6 en 7 allemaal binnen het window), langchain 132 (latest 1.5.10 op 2026-08-20), @openai/agents 83 (latest 0.17.0 op 2026-08-19, voor het eerst gepubliceerd op 2025-06-03). 2

  8. De Claude Agent SDK (@anthropic-ai/claude-agent-sdk) is de Claude Code harness verpakt als library — agent loop, ingebouwde file- en shell-tools, context management, sessions, hooks, permissions en subagents — gedocumenteerd op code.claude.com/docs/en/agent-sdk. Het komt het dichtst bij een gepubliceerd verslag van elk mechanisme dat dit hoofdstuk met de hand bouwt, en is het waard om naast je eigen implementatie te lezen voor de onderdelen die het benoemt en waar dit hoofdstuk alleen naar gebaart.

Klaar om LIA te laten kiezen?

Bouw met elk AI-model op één plek — begin vandaag nog gratis.