Ein Agent Harness bauen: Die Schleife und ihre fünf Ausgänge
Eine 15-Zeilen-Schleife, die sofort läuft – dann siebenmal absichtlich kaputtgeht, beginnend mit einem Runaway, der 77× mehr kostete.
Auf dieser Seite
Beginnen wir mit dem ehrlichen Teil, weil es sonst niemand sagen wird: „harness“ ist Jargon, kein Standard. Es gibt keine Spezifikation, kein Komitee, keine Referenzdefinition. Die vier Papers, die dieses Kapitel zitiert — ReAct,1 CoALA,2 SWE-bench und vLLM — verwenden das Wort in ihren Abstracts kein einziges Mal. Die meistgeladene Implementierung dieses Dings, Vercels ai-Paket mit 89,4 Millionen Downloads pro Monat, verwendet es ebenfalls nicht: Der String harness erscheint in den 397 KB Type-Declarations, die Version 7.0.93 ausliefert, genau nullmal.3 Die eine Stelle, an der das Wort tragend ist, meint etwas völlig anderes. SWE-bench sagt in seinem README fünfmal „harness“, immer als evaluation harness — das containerisierte Gerüst, das einen Patch anwendet und die Tests ausführt — und sein Python-Modul heißt buchstäblich swebench.harness.run_evaluation.4
Also teilen sich zwei verschiedene Dinge einen Namen. Ein evaluation harness hält den agent fest und bewertet ihn. Ein agent harness ist das Programm, das den agent ausführt: Es ruft das Modell auf, führt aus, was das Modell verlangt, entscheidet, wann Schluss ist, und hält dazwischen den Zustand. Dieses Kapitel baut das zweite: in unter zweihundert Zeilen TypeScript, ganz ohne Framework.
Die Schleife selbst hat fünfzehn Zeilen und funktioniert beim ersten Versuch. Alles danach ist ein Weg, sie zu verlassen.
Details anzeigen
Was dieses Kapitel aus den früheren braucht.
- Kapitel 14 für den Client: Deadlines, Status-Triage, Cancellation, Idempotency Keys und die Mock-Provider-Technik, die hier erneut verwendet wird.
- Kapitel 16 für die Arithmetik: Input-tokens wachsen mit dem Quadrat der Konversation, und die unten verwendeten Preise sind die, die dort am 6. September 2026 gelesen wurden.
- Kapitel 18 für den Tool-Katalog: ein Schema, das das Modell sieht, ein Endpoint, den es nie sieht, und die Regel, dass Fehler context statt Exceptions sind.
- Kapitel 22 für die Schleife, die diese hier erbt, und für die zwei veröffentlichten Definitionen von „agent“, die einander widersprechen.
Keine Tensoren hier. Dies ist der zweite Dependency-Hub des Kurses: Die Kapitel 24, 25, 29 und 30 laufen auf der Datei unten, und 26 bis 28 bauen auf dem auf, was sie erreichen kann.
Einen Provider, den du skripten kannst
Link zum Abschnitt: Einen Provider, den du skripten kannstKapitel 14 konnte nicht gegen einen echten Provider geschrieben werden, weil du einen echten Provider nicht bitten kannst, genau im gewünschten Moment eine 429 zu liefern. Dieses Kapitel hat dasselbe Problem in anderer Form: Du kannst ein echtes Modell nicht auf Kommando und reproduzierbar davonlaufen lassen oder zweimal hintereinander dasselbe Tool anfordern lassen.
Das erste Programm ist deshalb ein skriptbarer Provider: ein Endpoint in der Form einer Chat-Completions-API, dessen Antwort eine Funktion des Turn-Index und dessen ist, was die Tools bisher zurückgegeben haben. Er zählt tokens mit einem echten Byte-Pair-Encoder, sodass das Geld unten Arithmetik ist und keine Dekoration.
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);Zwei Zeilen tragen das Design. Der Turn-Index wird aus der Konversation abgeleitet, nicht in einer Variablen gehalten, sodass der Provider zustandslos ist und ein Run gegen ihn beendet und wiederaufgenommen werden kann. Und recover liest die Tool-Ergebnisse, bevor entschieden wird: Ein geskriptetes Modell, das sein eigenes Transkript liest, ist das Minimum, das nötig ist, um zu messen, ob der harness ihm überhaupt etwas Lesenswertes gegeben hat.
Der Katalog ist der aus Kapitel 18, vier Tools über drei Dateien: list_files, read_file, delete_file — markiert als needsApproval — und scan_archive, das absichtlich langsam ist.
Die Schleife, die funktioniert
Link zum Abschnitt: Die Schleife, die funktioniertHier ist die ganze Idee, bevor irgendeines der Teile dazukommt, die sie überlebensfähig machen.
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 });
}
}Richte sie auf den skriptbaren Provider, und sie tut genau das, wonach sie aussieht:
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, 342Drei Turns, zwei Tool-Ausführungen, ein Viertel eines US-Cents. Beachte die letzte Zeile: 204, 269, 342. Jeder Turn sendet alles davor erneut, das ist die quadratische Rechnung aus Kapitel 16, die an einer Stelle ankommt, an der niemand etwas getippt hat. Der Rest dieses Kapitels handelt davon, was passiert, wenn diese Zeile nicht aufhört zu wachsen.
Bruch eins: die Aufgabe, die nie endet
Link zum Abschnitt: Bruch eins: die Aufgabe, die nie endetRichte dieselbe Schleife auf das runaway-Skript — ein Modell, das in jedem einzelnen Turn nach einem Tool fragt und nie Prosa ausgibt — und das markierte return feuert nie. Es gibt keinen anderen Ausgang. Das Programm läuft, bis der Prozess stirbt oder die Kreditkarte.
Die Lösung ist eine Zeile, sie ist die erste Kontrolle, die die Literatur empfiehlt,5 und irgendwann schreibt sie jeder. Was fast niemand tut: messen, was sie wert ist:
| Turn-Cap | Modellaufrufe | Input-tokens | Kosten |
|---|---|---|---|
| 8 | 8 | 3.431 | $0.009070 |
| 20 | 20 | 16.259 | $0.038038 |
| 50 | 50 | 88.649 | $0.191098 |
| 100 | 100 | 337.299 | $0.702198 |
Lies die letzten beiden Zeilen zusammen. Das Cap von 50 auf 100 zu verdoppeln hat die Kosten nicht verdoppelt; es hat sie mit 3,7 multipliziert. Input-tokens stiegen von 88.649 auf 337.299, Faktor 3,8, weil Turn jeden vorherigen Turn mitträgt und die Summe ist. Ein Turn-Cap ist kein linearer Regler. Es ist ein Regler an der Quadratwurzel deines Worst Case, weshalb es eine Entscheidung ist, die du bepreisen solltest, bevor du sie triffst, wenn du es „nur zur Sicherheit“ von 20 auf 100 erhöhst.
Bruch zwei: Ein Turn-Cap ist kein Geld-Cap
Link zum Abschnitt: Bruch zwei: Ein Turn-Cap ist kein Geld-CapDas Problem mit einem Turn-Cap ist, dass ein Turn keinen festen Preis hat. Zwanzig Turns über ein kurzes Transkript kosteten oben $0.038. Zwanzig Turns mit einem 200-Tool-Katalog, einem Set abgerufener Dokumente und vierzig Nachrichten Historie kosten Hunderte Male so viel, und das Cap weiß das nicht. Was der Operator begrenzen will, ist die Rechnung.
Also zählt die Schleife Geld, mit Kapitel 16s computeCost gegen die dort gelesenen Preise — $2.00 pro Million Input-tokens und $12.00 pro Million Output, für das Modell, das in diesem Kurs durchgehend bepreist wird:
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);Dasselbe Runaway-Skript, ganz ohne Turn-Cap, drei Budgets:
| Budget | erreichte Turns | tatsächlich ausgegeben |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $0.205398 |
Zwei Dinge verdienen einen Namen. Erstens kauft das Budget jedes Mal eine andere Anzahl Turns, und genau darum geht es: Es begrenzt das, was dem Operator wichtig ist, und lässt die Turn-Zahl dort landen, wo das Transkript sie hinlegt. Zweitens: Jede Zeile überschießt. Das Budget war $0.010 und ausgegeben wurden $0.010780, weil der Check vor einem Turn läuft und der Preis eines Turns erst bekannt ist, wenn er vorbei ist. Du kannst Ausgaben nicht exakt begrenzen; du kannst sie auf die Kosten eines Turns genau begrenzen. Sag das in der Oberfläche, statt so zu tun, und setz den Check vor den Aufruf, damit der Überschuss ein Turn ist und nicht zwei.
Fünf Wege aus der Schleife, nicht einer
Link zum Abschnitt: Fünf Wege aus der Schleife, nicht einerInzwischen hat die Schleife drei Ausgänge, und die Form des restlichen Kapitels ist sichtbar. Ein Produktions-Run endet auf genau eine von fünf Arten, und sie sind keine Varianten voneinander:
| wie es endet | wer entschieden hat | was der Aufrufer tun sollte |
|---|---|---|
| das Modell hat aufgehört zu fragen | das Modell | die Antwort lesen |
| Turn-Cap | du, im Voraus | das Cap erhöhen oder ein Teilergebnis akzeptieren |
| Budget erschöpft | du, im Voraus | mehr Geld freigeben oder ein Teilergebnis akzeptieren |
| ein Fehler, den du nicht erneut versuchen kannst | der Provider oder ein Tool | das Deployment reparieren; die Triage aus Kapitel 14 entscheidet |
| ein Mensch hat eingegriffen | eine Person | auf ein Urteil warten, dann fortsetzen |
Diese in ein einzelnes Boolean zusammenzufalten ist der häufigste Designfehler in dieser Datei, und er ist auf eine bestimmte Weise teuer: Drei der fünf sind fortsetzbar, zwei nicht. Ein agent, der sein Turn-Cap erreicht hat, hat ein gültiges Transkript, ein echtes Teilergebnis und einen nächsten Schritt; ein agent, der eine 401 bekommen hat, hat nichts davon. Der harness speichert den Grund deshalb als Daten:
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 };Bruch drei: Ein Tool schlägt fehl
Link zum Abschnitt: Bruch drei: Ein Tool schlägt fehlKapitel 18 endete mit einer Behauptung ohne Zahl: Gib den Fehler eines Tools als Tool-Ergebnis an das Modell zurück, statt ihn zu werfen, und das Modell repariert sich meist selbst. Hier ist die Zahl.
Ein Fehler, drei Policies. Das geskriptete Modell rät eine Datei, die nicht existiert; das Tool wirft no such file: timeout.log. Call list_files to see what exists.
| was der harness mit dem Fehler macht | Turns | Tool-Runs | Kosten | was der Nutzer bekam |
|---|---|---|---|---|
| wirft ihn aus der Schleife | 1 | 1 | $0.000756 | einen Stacktrace |
gibt Error: the tool failed. zurück | 2 | 1 | $0.001462 | „Ich konnte die Datei nicht lesen, also weiß ich es nicht.“ |
| gibt zurück, was tatsächlich passiert ist | 4 | 3 | $0.003550 | „errors.log erwähnt ein Timeout.“ |
Die dritte Zeile kostet 4,7-mal so viel wie die erste und ist die einzige, die die Frage beantwortet. Und die zweite Zeile ist die interessante, weil sie das ist, was die meisten Codebases tatsächlich tun: Der Fehler wurde gefangen, die Schleife überlebte, dem Modell wurde gesagt, dass etwas fehlgeschlagen ist, aber nicht was, und es gab höflich auf. Der Unterschied zwischen Zeile zwei und drei ist kein Error Handling. Es ist ein Satz, der für einen Leser geschrieben wurde.
Der harness behandelt ein geworfenes Tool deshalb als Daten und macht die Formulierung zur 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 warnte auch vor der anderen Seite, und auch sie hat einen Preis. Richte die Schleife auf ein Tool, das aus einem Grund fehlschlägt, den keine Nachricht beheben kann — ein Lesezugriff, den der Prozess nicht ausführen darf — und das Modell versucht es für immer erneut:
read a file the process may not open turns=12 toolruns=11 in=7,079 cost=$0.018622
status=max_turns_exceeded answer=""Elf identische Ausführungen eines Calls, der nicht erfolgreich sein kann, 5,2-mal die Kosten des Runs, der sich von einem behebbaren Fehler erholte, und am Ende nichts. Fehler sind context; ein permanenter Fehler ist context, der den Rest des Runs vergiftet. Die Unterscheidung ist die Status-Triage aus Kapitel 14, eine Ebene nach oben verschoben: Ein Fehler, auf den das Modell reagieren kann, geht zurück ins Transkript, und ein Fehler, auf den es nicht reagieren kann, sollte den Run mit einem Grund stoppen. Das Turn-Cap steht heute zwischen dir und dem zweiten Fall; das ist ein Boden, keine Lösung.
Bruch vier: derselbe Call, zweimal
Link zum Abschnitt: Bruch vier: derselbe Call, zweimalJetzt der Fehler, von dem die meisten annehmen, dass er nicht passieren kann. Modelle wiederholen sich. Lass irgendeine Schleife lang genug laufen, und du wirst dasselbe Tool mit denselben Argumenten in zwei aufeinanderfolgenden Turns sehen.
Gemessen gegen die Baseline derselben Aufgabe ohne Wiederholung:
| Turns | Tool-Runs | Kosten | |
|---|---|---|---|
| die Aufgabe, keine Wiederholung | 2 | 1 | $0.001396 |
| dieselbe Aufgabe, ein Call wiederholt | 3 | 2 | $0.002446 |
| wiederholt, mit Result Cache auf Read-only-Tools | 3 | 1 | $0.002446 |
Der doppelte Call kostete $0.001050 extra, ein Anstieg von 75 %, und hier ist der Teil, der Leute überrascht: Caching des Ergebnisses holte nichts davon zurück. Deduplication sparte die Tool-Ausführung, aber nicht den Turn, denn bis dein Code die Wiederholung bemerkt, wurde das Modell bereits fürs Fragen bezahlt. Die Ersparnis ist real, wenn das Tool langsam, rate-limited oder pro Call abgerechnet ist — und sie ist null auf dem Posten, der gewachsen ist.
Es gibt eine schlimmere Version. Wende denselben Cache auf ein Tool an, das schreibt, und der zweite Call passiert stillschweigend nicht:
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"]Welche dieser Varianten ist korrekt? Keine, erkennbar. Das Protokoll sagt, das sind zwei Calls: Sie tragen zwei verschiedene tool_call_id-Werte. Die Argumente sagen, sie könnten einer sein. Ein harness, der anhand von Argument-Strings entscheidet, wird eines Tages die zweite von zwei identischen, beabsichtigten Abbuchungen verschlucken — und Kapitel 14 hat bereits den einzigen Mechanismus benannt, der das ehrlich auflöst: einen Idempotency Key, erzeugt pro logischer Operation von der Schicht, die weiß, was die Operation ist. Bis das Tool einen trägt, ist der vertretbare Default das Read-only-Gate oben: Reads cachen, Writes ausführen und den Write seine eigene Idempotency den Rest erledigen lassen.
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;
}Bruch fünf: Es löscht etwas
Link zum Abschnitt: Bruch fünf: Es löscht etwasDas destructive-Skript listet die Dateien auf und bittet dann darum, eine zu löschen, die die Aufgabe nie erwähnt hat. Nichts in der bisherigen Schleife würde es stoppen.
Ein Tool, das als needsApproval markiert ist, schlägt nicht fehl und läuft nicht weiter. Es stoppt den Run und gibt die Kontrolle zurück, mit allem, was eine Person braucht, um zu entscheiden:
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."Das ist der ganze Mechanismus, und der Grund, warum es ein Return statt eines Callbacks ist, kommt im nächsten Abschnitt: Zwischen Stop und Urteil existiert der Prozess möglicherweise nicht mehr.
Aber zuerst die Messung, die niemand erwartet. Eine Ablehnung ist nicht die Abwesenheit eines Ergebnisses — das Transkript hat einen Slot, der über tool_call_id geschlüsselt ist, und irgendetwas muss hinein. Führe dieselbe Ablehnung zweimal aus und ändere nur, was dieses Etwas sagt:
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 keinem der beiden Runs wurde etwas gelöscht, und im zweiten wird dem Nutzer gesagt, es sei gelöscht worden. Das Berechtigungssystem funktionierte perfekt; der Bericht ist eine Lüge. Es ist derselbe Mechanismus wie in der Tool-Fehler-Tabelle, nur an einer Stelle, an der er viel stärker zählt — ein Mensch sagte nein, die Aktion wurde korrekt blockiert, und die Zusammenfassung des agent widerspricht der Realität, weil die Verweigerung nie dort niedergeschrieben wurde, wo das Modell liest. Die daraus folgende Regel ist kurz: Was auch immer dein Code über einen Tool-Call entscheidet, schreibe die Entscheidung in Worten ins Transkript. Kapitel 30 kommt aus der Security-Perspektive darauf zurück, wo es der Unterschied zwischen Audit Trail und Fiktion ist.
Bruch sechs: Der Prozess stirbt
Link zum Abschnitt: Bruch sechs: Der Prozess stirbtEine Freigabe dauert Minuten oder Stunden. Ein Deploy dauert Sekunden. Wenn der Run in einer lokalen Variablen innerhalb eines HTTP-Requests lebt, ist jeder Neustart ein verlorener Run und jede Freigabe ein Race.
Der Run ist also keine Closure. Er ist ein plain serialisierbares Objekt — Nachrichten, Turn-Zahl, Kosten, Status, Unterbrechung, die Liste freigegebener Call-IDs — und die Schleife ist eine reine Funktion darüber. Diese eine Einschränkung macht Persistenz zu einem Einzeiler:
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"));Die Korrektheitsfrage ist nicht das Speichern. Sie ist, was auf dem Weg zurück passiert, und die naive Antwort berechnet dir doppelt. Wenn der Prozess starb, nachdem das Modell nach einem Tool gefragt hatte, aber bevor das Ergebnis geschrieben wurde, zahlt ein Resume, das damit beginnt, das Modell erneut aufzurufen, für einen Turn, den es bereits hat — und wenn es damit beginnt, die Tools erneut auszuführen, führt es einen Write zweimal aus.
Die Lösung ist, die Schleife damit beginnen zu lassen, das Transkript zu fragen, was noch offen ist:
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));
}Jede Iteration leert zuerst pending und fragt das Modell nur, wenn nichts mehr offen ist. Resume wird derselbe Codepfad wie der normale, und Approval ebenfalls — ein freigegebener Call ist einfach ein pending Call, der jetzt laufen darf. Beende den Prozess mitten in der Aufgabe und starte ihn neu:
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.logZwei Tool-Ausführungen über zwei Prozesse für eine Aufgabe, die zwei braucht, und die finalen Kosten sind identisch mit dem Run, der nie abgestürzt ist. Die Kosten akkumulieren über den Neustart hinweg, weil sie im State lagen, nicht in einer Variablen.
Bruch sieben: drei Minuten Stille
Link zum Abschnitt: Bruch sieben: drei Minuten Stillescan_archive dauert hier drei Sekunden und steht für das Tool, das in Produktion drei Minuten dauert. Während es läuft, fehlen zwei Dinge: Der Nutzer hat keine Ahnung, dass etwas passiert, und der Stop-Button tut nichts.
Beides hat dieselbe Lösung, und sie ist Kapitel 14s AbortSignal eine Ebene tiefer geschoben. Das Signal ist nicht nur für den Fetch — es wird ins Tool übergeben, und ein gut geschriebenes Tool respektiert es:
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"Zwei Millisekunden vom Klick bis zum Stop, weil der Sleep im Tool auf dasselbe Signal hört wie der Fetch. Threadest du es nur in fetch, wartet derselbe Stop-Button drei Sekunden — die Länge des Tools — und der Run „cancelt“, nachdem die Arbeit, die er canceln sollte, bereits fertig ist. Cancellation, die nicht ganz nach unten verlegt ist, ist ein Spinner, der das richtige Wort sagt.
Der Trace, und warum er kein Log ist
Link zum Abschnitt: Der Trace, und warum er kein Log istDer harness emittiert eine Zeile pro Event, und das Vokabular ist klein genug, um es auswendig zu lernen: 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}Drei Eigenschaften machen daraus einen Trace statt Logging. Jede Zeile trägt die Run-ID, sodass ein Run, der sich über drei Prozesse und zwei Tage erstreckt, eine einzige Query ist. Jede turn-Zeile trägt ihre eigenen token-Zählungen und die laufenden Kosten, sodass „warum hat dieser Run vierzig Dollar gekostet“ im Nachhinein beantwortbar ist, statt nur theoretisch reproduzierbar zu sein. Und run_stopped trägt den Grund, also das Feld, das ein Support-Ticket in eine Ein-Zeilen-Antwort verwandelt: Ein agent, der beim Budget stoppt, und ein agent, der crasht, sehen von außen identisch aus und brauchen gegenteilige Reaktionen.
Die Arithmetik der Latenz
Link zum Abschnitt: Die Arithmetik der LatenzKapitel 13 maß Time to first token auf Hardware, die dir gehört. Kapitel 14 maß sie durch einen Socket. Ein agent multipliziert sie, und der Multiplikator ist eine Zahl, die niemand gewählt hat:
Dieselbe Drei-Turn-Aufgabe, nur mit veränderter Provider-Latenz:
| Provider-Latenz pro Turn | Wall Clock, 3 Turns |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2.413 ms |
Der harness selbst trägt fünfzehn Millisekunden zu einem Drei-Turn-Run bei. Alles andere ist , multipliziert mit einer Zahl, die du nicht kontrollierst — gesetzt in einem Serving-Scheduler, der deinen Request mit den Requests Fremder batcht6 — und wird vom Modell gewählt. Deshalb zählt das Streaming aus Kapitel 14 hier mehr als in einem Chat und hilft weniger: Du kannst den finalen Turn streamen, und die vier Turns davor sind Stille, sofern der harness keinen Fortschritt emittiert. Das ist auch das ganze Argument für das tool_progress-Event oben — in einem agent ist die ehrliche Feedback-Einheit nicht das token, sondern der Schritt.
Derselbe harness, ein echtes Modell hinter dem Port
Link zum Abschnitt: Derselbe harness, ein echtes Modell hinter dem PortAlles oben lief gegen einen geskripteten Provider, was den harness beweist und nichts über Modelle. Also ändere eine Zeile — die Naht aus Kapitel 14, LLM_BASE_URL — und richte denselben Code auf ein lokales Qwen2.5-0.5B-Instruct mit denselben vier Tools. Sechs Aufgaben über dieselben drei Dateien:
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,908msDrei Erkenntnisse, und die dritte ist der Grund, warum dieser Abschnitt existiert.
Jede einzelne Aufgabe endete in genau zwei Turns. Das Turn-Cap feuerte nie, das Budget feuerte nie, und der einzige Ausgang der Schleife war, dass das Modell Prosa produzierte. Ein Modell mit einer halben Milliarde Parametern iteriert nicht; es antwortet beim zweiten Atemzug, ob es hat, was es braucht, oder nicht. Die Turn-Zahl ist eine Eigenschaft des Modells, nicht deiner Schleife.
Der mittlere Turn dauerte 6.908 Millisekunden, also ist die Latenztabelle oben kein Spielzeug: Bei dieser Größe ist ein hypothetischer Acht-Turn-Run fast eine Minute Wall Clock ohne irgendetwas auf dem Screen.
Und die Antworten sind falsch. Die größte Datei ist errors.log; das Modell listete die Dateien auf, las sie nie und nannte trotzdem eine. Die erste Aufgabe riet einen Dateinamen, bekam gesagt, dass er nicht existiert, und schloss ab. Der harness lief in allen sechs Runs fehlerfrei. Ein harness macht einen agent steuerbar, nicht korrekt — Kapitel 29 zeigt, wie du herausfindest, was davon zutrifft, und Kapitel 30, was es kostet, wenn niemand es getan hat.
Subagents, hier benannt und später berechnet
Link zum Abschnitt: Subagents, hier benannt und später berechnetEin Tool im Katalog kann einen weiteren Run hinter sich haben. Die Schnittstelle ist die aus Kapitel 18 — ein Schema und ein Endpoint — und ein ganzer agent passt dahinter, weil diese Schnittstelle schmal ist:
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";
},
};Drei Dinge sind in diesen zehn Zeilen bereits richtig, und alle drei sind Folgen der oben getroffenen Entscheidungen: Das Kind hat sein eigenes window, sodass das Transkript des Parents eine Zusammenfassung erhält statt allem, was das Kind gelesen hat; es hat seine eigenen Limits, sodass ein runaway Child nicht das Budget des Parents ausgeben kann; und es erbt das Signal, sodass ein Stop den Baum cancelt. Warum ein sauberes window der Punkt ist und kein Nebeneffekt, steht in Kapitel 24; die fünf Orchestrierungsmuster — prompt chaining, Routing, Parallelisierung, Orchestrator-Workers, Evaluator-Optimiser — und das handoff sind Kapitel 25.
Wo die Frameworks sind und warum dieser Kurs keines verwendet hat
Link zum Abschnitt: Wo die Frameworks sind und warum dieser Kurs keines verwendet hatNichts oben sollte als Argument gegen Libraries gelesen werden. Gemessen am 7. September 2026, für den Monat bis zum 29. August:7
| Paket | Downloads in diesem Monat | was es dir gibt |
|---|---|---|
ai (Vercel AI SDK) | 89.385.860 | ToolLoopAgent, stopWhen, Tool Approval, Step Hooks |
@anthropic-ai/claude-agent-sdk | 41.558.352 | der Claude Code harness als Library: Schleife, Sessions, Hooks, Permissions, subagents8 |
@langchain/langgraph | 12.812.815 | die Schleife als expliziter State Graph |
langchain | 11.359.058 | Chains, agents, Integrationen |
@openai/agents | 6.093.155 | agents, handoffs, guardrails |
@mastra/core | 5.914.502 | agents, Workflows, Memory |
Der Grund, warum dieser Kurs die Schleife von Hand schreibt, statt eines davon zu lehren, wird erklärt statt angedeutet, und er ist messbar. In den zwölf Monaten bis zum 7. September 2026 veröffentlichte ai 945 Versionen und wechselte von Major 5 zu Major 7, und seine agent-Klasse wird immer noch als Experimental_Agent exportiert; langchain veröffentlichte im selben Fenster 132 Versionen; @openai/agents veröffentlichte 83 und ist fünfzehn Monate nach dem ersten Release immer noch auf 0.x.7 Ein Kapitel, das gegen eine dieser APIs geschrieben ist, ist binnen einer Saison veraltet, und dieses hier erscheint in dreiunddreißig Sprachen, also kostet jede Neuauflage die ganze Übersetzung. Was unter all ihnen liegt, bewegt sich nicht: eine Schleife, eine Stop-Regel, ein Katalog, ein Executor, etwas State.
Und die Referenzimplementierung stimmt diesem Kapitel in dem Teil zu, der zählt. In ai Version 7.0.93 ist der Ausgang der Schleife keine Zahl — er ist stopWhen, eine Liste von Prädikaten, von denen eine Step-Zahl nur eines ist:3
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>; // exported as stepCountIsStoppen ist in der meistgenutzten Implementierung dieser Schleife plural, aus demselben Grund, aus dem es in den hundertsechsundneunzig Zeilen oben plural ist.
Wohin es als Nächstes geht
Link zum Abschnitt: Wohin es als Nächstes gehtDu hast jetzt einen harness: eine Schleife, einen Katalog, einen Executor, fünf Wege hinaus, einen persistierten Run, ein Signal, das die Tools erreicht, und einen Trace mit einer Run-ID in jeder Zeile. Die Kapitel 24, 25, 29 und 30 bauen auf dieser Datei auf, und 26 bis 28 auf dem, was sie erreichen kann.
Ein Problem bleibt, und die Messungen oben haben die ganze Zeit darauf gezeigt. Schau dir die Runaway-Tabelle noch einmal an: 3.431 Input-tokens bei acht Turns, 337.299 bei hundert. Schau dir den funktionierenden Run an: 204, 269, 342. Jeder Turn sendet das ganze Transkript erneut, also füllt sich der context eines agent mit seiner eigenen Historie — und das Modell ist schlechter darin, das ferne Ende eines langen window zu nutzen als das nahe, weshalb ein guter agent bei Turn fünf bei Turn vierzig ein verwirrter ist.
Ein Turn-Cap behebt das nicht. Es stoppt nur, dass du dafür bezahlst, ihm dabei zuzusehen. Was es behebt, ist in jedem einzelnen Turn zu entscheiden, welche tokens das window verdienen: was zu komprimieren ist, was in eine Notiz ausgelagert wird, die der agent abrufen kann, was an einen subagent mit sauberem window geht und welche Tool-Definitionen ihre permanente Steuer wert sind. Kapitel 24 misst, wo das window tatsächlich hingeht — und die Überraschung ist, dass es nicht die Konversation ist.
Quellen und Methode
Link zum Abschnitt: Quellen und MethodeJede Zahl in diesem Kapitel stammt aus den zwei oben beschriebenen Servern, auf Node 22 über eine Loopback-Schnittstelle: ein geskripteter Provider, der tokens mit der o200k_base-Kodierung zählt, und Qwen/Qwen2.5-0.5B-Instruct hinter einem Endpoint derselben Form, greedy decoding, auf CPU. Kosten werden aus gemessenen token-Zahlen zu den Preisen berechnet, die Kapitel 16 am 6. September 2026 gelesen hat — $2.00 pro Million Input-tokens und $12.00 pro Million Output — und kein Request in diesem Kapitel ging an einen bezahlten Endpoint. Die Antworten des lokalen Modells sind Antworten eines kleinen Modells; lies sie als Evidenz über die Schleife, die in beiden Fällen identisch ist, und nicht als Benchmark dessen, was aktuelle Modelle tun.
Referenzen
Link zum Abschnitt: Referenzen-
Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. und Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). Die Verschränkung von Reasoning Traces und Actions, die die Schleife implementiert, und die Quelle der Beobachtung, dass Acting ein Modell „handle exceptions“ lässt — genau das, was die Tool-Fehler-Tabelle oben misst. ↩
-
Sumers, T. R., Yao, S., Narasimhan, K. und Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). Die formale Behandlung dessen, was die Schleife oben informell tut: modulare Memory-Komponenten, ein strukturierter Aktionsraum, der interne Memory und externe Umgebungen umfasst, und „a generalized decision-making process to choose actions“. Lies es für das Vokabular, das dem Branchenbegriff fehlt — insbesondere die Trennung von Working, Episodic, Semantic und Procedural Memory, deren praktischer Schatten die Drei-Store-Tabelle aus Kapitel 24 ist. ↩
-
ai(Vercel AI SDK) Version 7.0.93, veröffentlicht am 4. September 2026; Type-Declarations gelesen auscdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.tsam 7. September 2026. Die 397-KB-Datei enthält null Vorkommen des Stringsharness. Die agent-Klasse istdeclare class ToolLoopAgent, exportiert sowohl alsToolLoopAgentals auch alsExperimental_Agent;declare function isStepCount(stepCount: number)— exportiert alsstepCountIs— wird oben wörtlich zitiert;type StopConditionwird ohne seinen zweiten Type-Parameter (RUNTIME_CONTEXT extends Context = Context) gezeigt, die einzige Auslassung im Auszug, ebenso wie die Form vonstopWhen?: Arrayable<StopCondition<...>>aufgenerateTextundstreamText. Dieselbe Datei deklarierttoolApproval,ToolApprovalStatus,prepareStepundrepairToolCall, das heißt, die Referenzimplementierung ist unabhängig bei Approval Gates, Per-Step Preparation und Error Repair angekommen. ↩ ↩2 -
Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. und Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). Der Abstract nennt das Artefakt ein „evaluation framework“ mit 2.294 Problemen und verwendet das Wort „harness“ nie; das eigene README des Projekts (
github.com/SWE-bench/SWE-bench, gelesen am 7. September 2026) verwendet es fünfmal, immer als „evaluation harness“, und der Entry Point istpython -m swebench.harness.run_evaluation. Das ist die andere Bedeutung des Wortes: ein Gerüst, das den agent festhält und bewertet, nicht die Schleife, die ihn ausführt. ↩ -
Anthropic, Building effective agents, 19. Dezember 2024,
anthropic.com/engineering/building-effective-agents, gelesen am 7. September 2026. Das augmented model als Building Block, der agent als LLM, das „using tools based on environmental feedback in a loop“ arbeitet, und die Empfehlung von Stopping Conditions „such as a maximum number of iterations“, um Kontrolle zu behalten. Kapitel 22 zitiert die Definition vollständig. ↩ -
Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. und Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). Die andere Schleife — der Serving-Scheduler, der deinen Request mit den Requests Fremder batcht und den KV cache aus Kapitel 13 verwaltet. Es lohnt sich zu wissen, dass sie existiert, gerade weil sie nicht dir gehört: Die Latenz, die dein harness multipliziert, wird darin gesetzt, und keine Arbeit an deiner Schleife verschiebt sie. ↩
-
Download-Zahlen aus der npm Registry,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, ein explizites Fenster statt des rollierendenlast-month-Fensters, und Release-Historien ausregistry.npmjs.org/<package>; beide abgefragt am 7. September 2026. Release-Zahlen sind die Anzahl der Versionen, die in den zwölf Monaten bis zu diesem Datum veröffentlicht wurden, Canary-Builds eingeschlossen:ai945 (latest 7.0.93 am 2026-09-04, mit Major-Versionen 5, 6 und 7 alle innerhalb des Fensters),langchain132 (latest 1.5.10 am 2026-08-20),@openai/agents83 (latest 0.17.0 am 2026-08-19, erstmals veröffentlicht 2025-06-03). ↩ ↩2 -
Das Claude Agent SDK (
@anthropic-ai/claude-agent-sdk) ist der Claude Code harness als Library verpackt — agent loop, eingebaute Datei- und Shell-Tools, context management, Sessions, Hooks, Permissions und subagents — dokumentiert untercode.claude.com/docs/en/agent-sdk. Es ist das Nächste an einer veröffentlichten Darstellung jedes Mechanismus, den dieses Kapitel von Hand baut, und es lohnt sich, es neben deiner eigenen Implementierung zu lesen für die Teile, die es benennt und dieses Kapitel nur andeutet. ↩