Construire un agent harness : la boucle et ses cinq issues
Une boucle de quinze lignes qui marche du premier coup, puis cassée sept fois exprès — dont une dérive coûtant 77 fois plus cher.
Dans cet article
Commençons par la partie honnête, parce que personne d’autre ne le dira : « harness » est du jargon, pas un standard. Il n’existe ni spécification, ni comité, ni définition de référence. Les quatre articles cités dans ce chapitre — ReAct,1 CoALA,2 SWE-bench et vLLM — n’emploient pas une seule fois le mot dans leurs résumés. L’implémentation la plus téléchargée de la chose, le package ai de Vercel avec 89,4 millions de téléchargements par mois, ne l’emploie pas non plus : la chaîne harness apparaît zéro fois dans les 397 Ko de déclarations de types livrées par la version 7.0.93.3 Le seul endroit où le mot porte vraiment le sens désigne quelque chose de tout à fait différent. SWE-bench dit « harness » cinq fois dans son README, toujours comme evaluation harness — l’échafaudage conteneurisé qui applique un patch et lance les tests — et son module Python est littéralement swebench.harness.run_evaluation.4
Deux choses différentes partagent donc un nom. Un evaluation harness immobilise l’agent et le note. Un agent harness est le programme qui exécute l’agent : il appelle le modèle, exécute ce que le modèle demande, décide quand s’arrêter et conserve l’état entre deux étapes. Ce chapitre construit le second, en moins de deux cents lignes de TypeScript, sans aucun framework.
La boucle elle-même tient en quinze lignes et marche au premier essai. Tout ce qui suit est une manière d’en sortir.
Afficher les détails
Ce dont ce chapitre a besoin des précédents.
- Chapitre 14 pour le client : deadlines, triage des statuts, annulation, clés d’idempotence et la technique du fournisseur simulé réutilisée ici.
- Chapitre 16 pour l’arithmétique : les tokens d’entrée croissent avec le carré de la conversation, et les tarifs utilisés ci-dessous sont ceux relevés là-bas le 6 septembre 2026.
- Chapitre 18 pour le catalogue d’outils : un schéma que le modèle voit, un endpoint qu’il ne voit jamais et la règle selon laquelle les erreurs sont du context plutôt que des exceptions.
- Chapitre 22 pour la boucle dont celui-ci hérite, et pour les deux définitions publiées d’« agent » qui se contredisent.
Pas de tenseurs ici. C’est le deuxième hub de dépendances du cours : les chapitres 24, 25, 29 et 30 reposent sur le fichier ci-dessous, et les chapitres 26 à 28 construisent sur ce qu’il peut atteindre.
Un fournisseur que vous pouvez scénariser
Lien vers la section : Un fournisseur que vous pouvez scénariserLe chapitre 14 ne pouvait pas être écrit contre un vrai fournisseur, parce que vous ne pouvez pas lui demander un 429 à un moment précis. Ce chapitre rencontre le même problème sous une autre forme : vous ne pouvez pas demander à un vrai modèle de s’emballer, ou de demander deux fois de suite le même outil avec les mêmes arguments, à la demande et de manière reproductible.
Le premier programme est donc un fournisseur scénarisé : un endpoint ayant la forme d’une API de chat completions dont la réponse est une fonction de l’indice du tour et de ce que les outils ont renvoyé jusque-là. Il compte les tokens avec un véritable encodeur byte-pair, donc l’argent ci-dessous relève de l’arithmétique, pas de la décoration.
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);Deux lignes portent le design. L’indice du tour est dérivé de la conversation, pas conservé dans une variable, donc le fournisseur est sans état et une exécution peut être tuée puis reprise contre lui. Et recover lit les résultats des outils avant de décider : un modèle scénarisé qui lit sa propre transcription est le minimum nécessaire pour mesurer si le harness lui a donné quelque chose qui mérite d’être lu.
Le catalogue est celui du chapitre 18, quatre outils sur trois fichiers : list_files, read_file, delete_file — marqué needsApproval — et scan_archive, qui est lent exprès.
La boucle qui marche
Lien vers la section : La boucle qui marcheVoici toute l’idée, avant toutes les parties qui la rendent viable.
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 });
}
}Pointez-la vers le fournisseur scénarisé et elle fait exactement ce qu’elle semble faire :
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, 342Trois tours, deux exécutions d’outils, un quart de centime américain. Notez la dernière ligne : 204, 269, 342. Chaque tour renvoie tout ce qui le précède, ce qui est la facture quadratique du chapitre 16 qui arrive à un endroit où personne n’a rien saisi. Le reste de ce chapitre décrit ce qui se passe quand cette ligne ne cesse pas de grandir.
Première casse : la tâche qui ne finit jamais
Lien vers la section : Première casse : la tâche qui ne finit jamaisPointez la même boucle vers le script runaway — un modèle qui demande un outil à chaque tour et n’émet jamais de prose — et le return marqué ne se déclenche jamais. Il n’y a pas d’autre sortie. Le programme tourne jusqu’à ce que le processus meure, ou la carte bancaire.
Le correctif tient en une ligne, c’est le premier contrôle recommandé par la littérature,5 et tout le monde finit par l’écrire. Ce que presque personne ne fait, c’est mesurer ce qu’il vaut :
| plafond de tours | appels au modèle | tokens d’entrée | coût |
|---|---|---|---|
| 8 | 8 | 3 431 | $0.009070 |
| 20 | 20 | 16 259 | $0.038038 |
| 50 | 50 | 88 649 | $0.191098 |
| 100 | 100 | 337 299 | $0.702198 |
Lisez les deux dernières lignes ensemble. Doubler le plafond de 50 à 100 n’a pas doublé le coût ; cela l’a multiplié par 3,7. Les tokens d’entrée sont passés de 88 649 à 337 299, soit un facteur de 3,8, parce que le tour transporte chaque tour précédent avec lui et que le total est . Un plafond de tours n’est pas un cadran linéaire. C’est un cadran sur la racine carrée de votre pire cas, voilà pourquoi le relever de 20 à 100 « juste pour être sûr » est une décision qu’il vaut la peine de chiffrer avant de la prendre.
Deuxième casse : un plafond de tours n’est pas un plafond de dépenses
Lien vers la section : Deuxième casse : un plafond de tours n’est pas un plafond de dépensesLe problème d’un plafond de tours, c’est qu’un tour n’a pas de prix fixe. Vingt tours sur une transcription courte coûtent $0.038 ci-dessus. Vingt tours avec un catalogue de 200 outils, un ensemble de documents récupérés et quarante messages d’historique coûtent des centaines de fois plus, et le plafond ne le sait pas. Ce que l’opérateur veut borner, c’est la facture.
La boucle compte donc l’argent, en utilisant le computeCost du chapitre 16 avec les tarifs lus là-bas — $2.00 par million de tokens d’entrée et $12.00 par million en sortie, pour le modèle tarifé tout au long de ce cours :
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);Même script d’emballement, sans aucun plafond de tours, trois budgets :
| budget | tours atteints | réellement dépensé |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $0.205398 |
Deux choses méritent d’être nommées. D’abord, le budget achète un nombre de tours différent à chaque fois, et c’est précisément le but : il borne ce qui compte pour l’opérateur, en laissant le nombre de tours tomber là où la transcription le place. Ensuite, chaque ligne dépasse. Le budget était de $0.010 et $0.010780 ont été dépensés, parce que la vérification s’exécute avant un tour et que le prix d’un tour n’est connu qu’une fois celui-ci terminé. Vous ne pouvez pas borner la dépense exactement ; vous pouvez la borner à un coût de tour près. Dites-le dans l’interface plutôt que de faire semblant, et placez la vérification avant l’appel pour que le dépassement soit d’un tour et non de deux.
Cinq façons de sortir de la boucle, pas une
Lien vers la section : Cinq façons de sortir de la boucle, pas uneÀ ce stade, la boucle a trois sorties, et la forme du reste du chapitre est visible. Une exécution de production se termine exactement de l’une de cinq façons, et ce ne sont pas des variantes les unes des autres :
| comment cela se termine | qui a décidé | ce que l’appelant doit faire |
|---|---|---|
| le modèle a cessé de demander | le modèle | lire la réponse |
| plafond de tours | vous, à l’avance | relever le plafond, ou accepter un résultat partiel |
| budget épuisé | vous, à l’avance | approuver davantage d’argent, ou accepter un résultat partiel |
| une erreur que vous ne pouvez pas retenter | le fournisseur ou un outil | corriger le déploiement ; le triage du chapitre 14 décide |
| un humain est intervenu | une personne | attendre un verdict, puis reprendre |
Réduire tout cela à un booléen est l’erreur de design la plus courante dans ce fichier, et elle coûte cher d’une manière précise : trois des cinq états sont reprenables et deux ne le sont pas. Un agent qui a atteint son plafond de tours a une transcription valide, un vrai résultat partiel et une prochaine étape ; un agent qui a reçu un 401 n’a rien de tout cela. Le harness enregistre donc la raison comme une donnée :
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 };Troisième casse : un outil échoue
Lien vers la section : Troisième casse : un outil échoueLe chapitre 18 se terminait sur une affirmation sans chiffre : renvoyez l’erreur d’un outil au modèle comme résultat d’outil plutôt que de la lever, et le modèle se corrige souvent tout seul. Voici le chiffre.
Un échec, trois politiques. Le modèle scénarisé devine un fichier qui n’existe pas ; l’outil lève no such file: timeout.log. Call list_files to see what exists.
| ce que le harness fait de l’erreur | tours | exécutions d’outils | coût | ce que l’utilisateur a obtenu |
|---|---|---|---|---|
| la jette hors de la boucle | 1 | 1 | $0.000756 | une stack trace |
renvoie Error: the tool failed. | 2 | 1 | $0.001462 | « Je n’ai pas pu lire le fichier, donc je ne sais pas. » |
| renvoie ce qui s’est réellement passé | 4 | 3 | $0.003550 | « errors.log mentionne un timeout. » |
La troisième ligne coûte 4,7 fois la première et c’est la seule qui répond à la question. Et la deuxième ligne est intéressante, parce que c’est ce que font réellement la plupart des bases de code : l’erreur a été interceptée, la boucle a survécu, le modèle a été informé que quelque chose a échoué, mais pas quoi, et il a abandonné poliment. La différence entre les lignes deux et trois n’est pas la gestion d’erreurs. C’est une phrase écrite pour un lecteur.
Le harness traite donc un outil qui lève comme une donnée, et fait de la formulation une politique :
} 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);
}Le chapitre 18 avertissait aussi de l’autre côté, et lui aussi a un prix. Pointez la boucle vers un outil qui échoue pour une raison qu’aucun message ne peut corriger — une lecture que le processus n’a pas le droit d’effectuer — et le modèle la retente indéfiniment :
read a file the process may not open turns=12 toolruns=11 in=7,079 cost=$0.018622
status=max_turns_exceeded answer=""Onze exécutions identiques d’un appel qui ne peut pas réussir, 5,2 fois le coût de l’exécution qui a récupéré d’une erreur corrigeable, et rien à la fin. Les erreurs sont du context ; une erreur permanente est du context qui empoisonne le reste de l’exécution. La distinction est le triage des statuts du chapitre 14 remonté d’une couche : une erreur sur laquelle le modèle peut agir retourne dans la transcription, et une erreur sur laquelle il ne peut pas agir devrait arrêter l’exécution avec une raison. Aujourd’hui, le plafond de tours est ce qui vous sépare du second cas, ce qui est un plancher, pas une correction.
Quatrième casse : le même appel, deux fois
Lien vers la section : Quatrième casse : le même appel, deux foisVoici maintenant l’échec que la plupart des gens supposent impossible. Les modèles se répètent. Demandez à n’importe quelle boucle de tourner assez longtemps et vous verrez le même outil avec les mêmes arguments sur deux tours consécutifs.
Mesuré par rapport à la base de référence de la même tâche sans répétition :
| tours | exécutions d’outils | coût | |
|---|---|---|---|
| la tâche, sans répétition | 2 | 1 | $0.001396 |
| la même tâche, un appel répété | 3 | 2 | $0.002446 |
| répété, avec un cache de résultats sur les outils en lecture seule | 3 | 1 | $0.002446 |
L’appel dupliqué a coûté $0.001050 de plus, soit une hausse de 75 %, et voici ce qui surprend les gens : le cache du résultat n’en a récupéré aucune partie. La déduplication a économisé l’exécution de l’outil, pas le tour, parce qu’au moment où votre code remarque la répétition, le modèle a déjà été payé pour l’avoir demandée. L’économie est réelle quand l’outil est lent, limité en débit ou facturé à l’appel — et elle est nulle sur la ligne de facture qui a grossi.
Il existe une version pire. Appliquez le même cache à un outil qui écrit, et le deuxième appel n’a silencieusement pas lieu :
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"]Lequel est correct ? Aucun, à ce qu’on peut savoir. Le protocole dit qu’il s’agit de deux appels : ils portent deux valeurs tool_call_id différentes. Les arguments disent qu’ils pourraient n’en faire qu’un. Un harness qui décide en comparant des chaînes d’arguments finira un jour par avaler le second de deux débits identiques et intentionnels — et le chapitre 14 a déjà nommé le seul mécanisme qui résout cela honnêtement, à savoir une clé d’idempotence générée par opération logique par la couche qui sait ce qu’est l’opération. Tant que l’outil n’en transporte pas, le défaut défendable est la barrière lecture seule ci-dessus : mettez les lectures en cache, exécutez les écritures, et laissez l’idempotence propre de l’écriture gérer le reste.
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;
}Cinquième casse : il supprime quelque chose
Lien vers la section : Cinquième casse : il supprime quelque choseLe script destructive liste les fichiers puis demande à en supprimer un que la tâche n’a jamais mentionné. Rien dans la boucle jusqu’ici ne l’arrêterait.
Un outil marqué needsApproval n’échoue pas et ne poursuit pas. Il arrête l’exécution et rend la main, avec tout ce dont une personne a besoin pour décider :
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."C’est tout le mécanisme, et la raison pour laquelle il s’agit d’un retour plutôt que d’un callback est la section suivante : entre l’arrêt et le verdict, le processus peut ne plus exister.
Mais d’abord, la mesure que personne n’attend. Un refus n’est pas l’absence de résultat — la transcription a un emplacement indexé par tool_call_id et quelque chose doit y entrer. Exécutez deux fois le même refus, en changeant uniquement ce que dit ce quelque chose :
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."Rien n’a été supprimé dans aucune des deux exécutions, et dans la seconde l’utilisateur est informé que cela l’a été. Le système d’autorisations a parfaitement fonctionné ; le rapport est un mensonge. C’est le même mécanisme que dans le tableau des erreurs d’outils, arrivé à un endroit beaucoup plus important — un humain a dit non, l’action a été correctement bloquée, et le résumé de l’agent contredit la réalité parce que le refus n’a jamais été écrit là où le modèle lit. La règle qui en découle est courte : quoi que votre code décide à propos d’un appel d’outil, écrivez la décision dans la transcription, avec des mots. Le chapitre 30 y revient côté sécurité, où c’est la différence entre une piste d’audit et une fiction.
Sixième casse : le processus meurt
Lien vers la section : Sixième casse : le processus meurtUne approbation prend des minutes ou des heures. Un déploiement prend des secondes. Si l’exécution vit dans une variable locale à l’intérieur d’une requête HTTP, chaque redémarrage est une exécution perdue et chaque approbation est une course.
L’exécution n’est donc pas une closure. C’est un objet simple sérialisable — messages, nombre de tours, coût, statut, interruption, liste des ids d’appels approuvés — et la boucle est une fonction pure sur cet objet. Cette unique contrainte réduit la persistance à une préoccupation d’une ligne :
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"));La question de correction n’est pas la sauvegarde. C’est ce qui se passe au retour, et la réponse naïve vous facture deux fois. Si le processus est mort après que le modèle a demandé un outil mais avant que le résultat soit écrit, une reprise qui commence par rappeler le modèle paie un tour qu’elle a déjà — et si elle commence par réexécuter les outils, elle effectue une écriture deux fois.
Le correctif consiste à faire commencer la boucle par une question à la transcription : qu’est-ce qui reste en suspens ?
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));
}Chaque itération vide d’abord pending et ne demande le modèle que lorsqu’il ne reste rien en suspens. La reprise devient le même chemin de code que le cas normal, tout comme l’approbation — un appel approuvé est simplement un appel pending désormais autorisé à s’exécuter. Tuez le processus au milieu de la tâche et redémarrez-le :
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.logDeux exécutions d’outils sur deux processus pour une tâche qui en nécessite deux, et le coût final est identique à celui de l’exécution qui n’a jamais planté. Le coût s’accumule à travers le redémarrage parce qu’il était dans l’état, pas dans une variable.
Septième casse : trois minutes de silence
Lien vers la section : Septième casse : trois minutes de silencescan_archive prend trois secondes ici et représente l’outil qui prend trois minutes en production. Deux choses manquent pendant son exécution : l’utilisateur n’a aucune idée que quelque chose se passe, et le bouton Stop ne fait rien.
Les deux ont le même correctif, et c’est le AbortSignal du chapitre 14 poussé un niveau plus profond. Le signal n’est pas seulement destiné au fetch — il est passé dans l’outil, et un outil bien écrit l’honore :
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"Deux millisecondes entre le clic et l’arrêt, parce que le sleep dans l’outil écoute le même signal que le fetch. Faites-le passer seulement dans fetch et le même bouton Stop attend trois secondes — la durée de l’outil — et l’exécution « s’annule » après que le travail qu’elle annulait est déjà terminé. Une annulation qui n’est pas câblée jusqu’en bas est un spinner qui affiche le bon mot.
La trace, et pourquoi ce n’est pas un log
Lien vers la section : La trace, et pourquoi ce n’est pas un logLe harness émet une ligne par événement, et le vocabulaire est assez petit pour être mémorisé : 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}Trois propriétés en font une trace plutôt que du logging. Chaque ligne porte le run id, donc une exécution qui s’étend sur trois processus et deux jours reste une seule requête. Chaque ligne turn porte ses propres décomptes de tokens et le coût cumulé, donc « pourquoi cette exécution a-t-elle coûté quarante dollars ? » trouve une réponse après coup au lieu de n’être reproductible qu’en théorie. Et run_stopped porte la raison, le champ qui transforme un ticket support en réponse d’une ligne : un agent qui s’est arrêté au budget et un agent qui a planté se ressemblent de l’extérieur et exigent des réponses opposées.
L’arithmétique de la latence
Lien vers la section : L’arithmétique de la latenceLe chapitre 13 mesurait le temps jusqu’au premier token sur du matériel que vous possédez. Le chapitre 14 le mesurait à travers un socket. Un agent le multiplie, et le multiplicateur est un nombre que personne n’a choisi :
La même tâche en trois tours, en changeant uniquement la latence du fournisseur :
| latence du fournisseur par tour | temps réel, 3 tours |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2 413 ms |
Le harness lui-même ajoute quinze millisecondes à une exécution en trois tours. Tout le reste est multiplié par un nombre que vous ne contrôlez pas — défini dans un ordonnanceur de serving qui batch votre requête avec celles d’inconnus6 — et est choisi par le modèle. Voilà pourquoi le streaming du chapitre 14 compte plus ici que dans un chat et aide moins : vous pouvez streamer le dernier tour, et les quatre tours qui le précèdent sont du silence sauf si le harness émet de la progression. C’est aussi tout l’argument en faveur de l’événement tool_progress ci-dessus — dans un agent, l’unité honnête de feedback n’est pas le token, c’est l’étape.
Le même harness, avec un vrai modèle derrière le port
Lien vers la section : Le même harness, avec un vrai modèle derrière le portTout ce qui précède tournait contre un fournisseur scénarisé, ce qui prouve le harness et ne prouve rien sur les modèles. Changez donc une ligne — la couture du chapitre 14, LLM_BASE_URL — et pointez le même code vers un Qwen2.5-0.5B-Instruct local avec les mêmes quatre outils. Six tâches sur les trois mêmes fichiers :
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,908msTrois constats, et le troisième est la raison d’être de cette section.
Chaque tâche s’est terminée en exactement deux tours. Le plafond de tours ne s’est jamais déclenché, le budget non plus, et la seule sortie de la boucle a été la production de prose par le modèle. Un modèle d’un demi-milliard de paramètres n’itère pas ; il répond à son deuxième souffle, qu’il ait ou non ce dont il a besoin. Le nombre de tours est une propriété du modèle, pas de votre boucle.
Le tour moyen a pris 6 908 millisecondes, donc le tableau de latence ci-dessus n’est pas un jouet : à cette taille, une exécution hypothétique de huit tours représente presque une minute de temps réel sans rien à l’écran.
Et les réponses sont fausses. Le plus gros fichier est errors.log ; le modèle a listé les fichiers, ne les a jamais lus et en a quand même nommé un. La première tâche a deviné un nom de fichier, a été informée qu’il n’existait pas, puis a conclu. Le harness s’est exécuté sans faute dans les six runs. Un harness rend un agent gouvernable, pas correct — le chapitre 29 explique comment découvrir lequel, et le chapitre 30 montre ce que cela coûte quand personne ne l’a fait.
Subagents, nommés ici et facturés plus tard
Lien vers la section : Subagents, nommés ici et facturés plus tardUn outil du catalogue peut avoir une autre exécution derrière lui. L’interface est celle du chapitre 18 — un schéma et un endpoint — et un agent entier tient derrière elle parce que cette interface est étroite :
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";
},
};Trois choses sont déjà justes dans ces dix lignes, et toutes trois découlent des décisions prises ci-dessus : l’enfant a sa propre fenêtre, donc la transcription du parent reçoit un résumé plutôt que tout ce que l’enfant a lu ; il a ses propres limites, donc un enfant qui s’emballe ne peut pas dépenser le budget du parent ; et il hérite du signal, donc un seul Stop annule l’arbre. Le chapitre 24 explique pourquoi une fenêtre propre est le point central plutôt qu’un effet de bord ; les cinq schémas d’orchestration — prompt chaining, routing, parallélisation, orchestrator-workers, evaluator-optimiser — et le handoff sont au chapitre 25.
Où sont les frameworks, et pourquoi ce cours n’en a pas utilisé
Lien vers la section : Où sont les frameworks, et pourquoi ce cours n’en a pas utiliséRien de ce qui précède ne doit être lu comme un argument contre les bibliothèques. Mesuré le 7 septembre 2026, pour le mois se terminant le 29 août :7
| package | téléchargements ce mois-là | ce qu’il vous donne |
|---|---|---|
ai (Vercel AI SDK) | 89 385 860 | ToolLoopAgent, stopWhen, approbation d’outils, hooks d’étapes |
@anthropic-ai/claude-agent-sdk | 41 558 352 | le harness Claude Code comme bibliothèque : boucle, sessions, hooks, permissions, subagents8 |
@langchain/langgraph | 12 812 815 | la boucle comme graphe d’état explicite |
langchain | 11 359 058 | chaînes, agents, intégrations |
@openai/agents | 6 093 155 | agents, handoffs, guardrails |
@mastra/core | 5 914 502 | agents, workflows, mémoire |
La raison pour laquelle ce cours écrit la boucle à la main au lieu d’enseigner l’un d’eux est déclarée plutôt que sous-entendue, et elle est mesurable. Dans les douze mois précédant le 7 septembre 2026, ai a publié 945 versions et est passé de la majeure 5 à la majeure 7, et sa classe d’agent est toujours exportée comme Experimental_Agent ; langchain a publié 132 versions dans la même fenêtre ; @openai/agents en a publié 83 et est toujours en 0.x, quinze mois après sa première publication.7 Un chapitre écrit contre l’une de ces API devient périmé en une saison, et celui-ci est publié en trente-trois langues, donc chaque réédition coûte toute la traduction. Ce qui se trouve sous toutes ces bibliothèques ne bouge pas : une boucle, une règle d’arrêt, un catalogue, un exécuteur, un peu d’état.
Et l’implémentation de référence est d’accord avec ce chapitre sur la partie qui compte. Dans la version 7.0.93 de ai, la sortie de la boucle n’est pas un nombre — c’est stopWhen, une liste de prédicats, dont le nombre d’étapes n’est qu’un élément :3
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>; // exported as stepCountIsL’arrêt est pluriel dans l’implémentation la plus utilisée de cette boucle, pour la même raison qu’il est pluriel dans les cent quatre-vingt-seize lignes ci-dessus.
Où cela mène ensuite
Lien vers la section : Où cela mène ensuiteVous avez maintenant un harness : une boucle, un catalogue, un exécuteur, cinq sorties, une exécution persistée, un signal qui atteint les outils et une trace avec un run id sur chaque ligne. Les chapitres 24, 25, 29 et 30 construisent sur ce fichier, et les chapitres 26 à 28 sur ce qu’il peut atteindre.
Il lui reste un problème, et les mesures ci-dessus le désignent depuis le début. Regardez encore une fois le tableau d’emballement : 3 431 tokens d’entrée à huit tours, 337 299 à cent. Regardez l’exécution qui marche : 204, 269, 342. Chaque tour renvoie toute la transcription, donc le context d’un agent se remplit de sa propre histoire — et le modèle exploite moins bien l’extrémité lointaine d’une longue fenêtre que son extrémité proche, voilà pourquoi un bon agent au tour cinq devient confus au tour quarante.
Un plafond de tours ne corrige pas cela. Il vous évite seulement de payer pour le regarder arriver. Ce qui le corrige, c’est décider, à chaque tour, quels tokens méritent la fenêtre : quoi compacter, quoi déplacer dans une note que l’agent peut récupérer, quoi confier à un subagent avec une fenêtre propre, et quelles définitions d’outils valent leur taxe permanente. Le chapitre 24 mesure où part réellement la fenêtre — et la surprise, c’est que ce n’est pas dans la conversation.
Sources et méthode
Lien vers la section : Sources et méthodeChaque chiffre de ce chapitre est sorti des deux serveurs décrits ci-dessus, sur Node 22 via une interface loopback : un fournisseur scénarisé comptant les tokens avec l’encodage o200k_base, et Qwen/Qwen2.5-0.5B-Instruct derrière un endpoint de même forme, greedy decoding, sur CPU. Les coûts sont calculés à partir des décomptes de tokens mesurés aux tarifs relevés par le chapitre 16 le 6 septembre 2026 — $2.00 par million de tokens d’entrée et $12.00 par million en sortie — et aucune requête de ce chapitre n’est partie vers un endpoint payant. Les réponses du modèle local sont les réponses d’un petit modèle ; lisez-les comme une preuve concernant la boucle, identique dans les deux cas, et non comme un benchmark de ce que font les modèles actuels.
Références
Lien vers la section : Références-
Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. et Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). L’entrelacement de traces de raisonnement et d’actions que la boucle implémente, et la source de l’observation selon laquelle agir permet à un modèle de « handle exceptions » — exactement ce que mesure le tableau des erreurs d’outils ci-dessus. ↩
-
Sumers, T. R., Yao, S., Narasimhan, K. et Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). Le traitement formel de ce que la boucle ci-dessus fait informellement : composants de mémoire modulaires, espace d’action structuré couvrant la mémoire interne et les environnements externes, et « a generalized decision-making process to choose actions ». Lisez-le pour le vocabulaire qui manque au terme de l’industrie — en particulier la séparation entre mémoire de travail, épisodique, sémantique et procédurale, dont l’ombre pratique est le tableau des trois stores du chapitre 24. ↩
-
ai(Vercel AI SDK) version 7.0.93, publiée le 4 septembre 2026 ; déclarations de types lues depuiscdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.tsle 7 septembre 2026. Le fichier de 397 Ko ne contient aucune occurrence de la chaîneharness. La classe d’agent estdeclare class ToolLoopAgent, exportée à la fois commeToolLoopAgentet commeExperimental_Agent;declare function isStepCount(stepCount: number)— exporté commestepCountIs— est cité mot pour mot ci-dessus ;type StopConditionest montré sans son second paramètre de type (RUNTIME_CONTEXT extends Context = Context), qui est la seule omission dans l’extrait, tout comme la forme destopWhen?: Arrayable<StopCondition<...>>surgenerateTextetstreamText. Le même fichier déclaretoolApproval,ToolApprovalStatus,prepareStepetrepairToolCall, ce qui revient à dire que l’implémentation de référence est arrivée indépendamment aux portes d’approbation, à la préparation par étape et à la réparation d’erreurs. ↩ ↩2 -
Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. et Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). Le résumé appelle l’artefact un « evaluation framework » de 2 294 problèmes et n’emploie jamais le mot « harness » ; le README du projet lui-même (
github.com/SWE-bench/SWE-bench, lu le 7 septembre 2026) l’emploie cinq fois, toujours comme « evaluation harness », et le point d’entrée estpython -m swebench.harness.run_evaluation. C’est l’autre sens du mot : un échafaudage qui immobilise l’agent et le note, pas la boucle qui l’exécute. ↩ -
Anthropic, Building effective agents, 19 décembre 2024,
anthropic.com/engineering/building-effective-agents, lu le 7 septembre 2026. Le modèle augmenté comme brique de base, l’agent comme LLM « using tools based on environmental feedback in a loop », et la recommandation de conditions d’arrêt « such as a maximum number of iterations » pour garder le contrôle. Le chapitre 22 cite sa définition en entier. ↩ -
Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. et Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). L’autre boucle — l’ordonnanceur de serving qui batch votre requête avec celles d’inconnus et gère le KV cache du chapitre 13. Cela vaut la peine de savoir qu’il existe précisément parce qu’il ne vous appartient pas : la latence que votre harness multiplie est définie dedans, et aucun travail sur votre boucle ne la déplace. ↩
-
Comptages de téléchargements du registre npm,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, une fenêtre explicite plutôt que la fenêtre glissantelast-month, et historiques de versions depuisregistry.npmjs.org/<package>; les deux consultés le 7 septembre 2026. Les comptages de versions sont le nombre de versions publiées dans les douze mois précédant cette date, canary builds inclus :ai945 (dernière 7.0.93 le 2026-09-04, avec les versions majeures 5, 6 et 7 toutes présentes dans la fenêtre),langchain132 (dernière 1.5.10 le 2026-08-20),@openai/agents83 (dernière 0.17.0 le 2026-08-19, première publication le 2025-06-03). ↩ ↩2 -
Le Claude Agent SDK (
@anthropic-ai/claude-agent-sdk) est le harness Claude Code empaqueté comme bibliothèque — boucle d’agent, outils intégrés de fichiers et de shell, gestion du context, sessions, hooks, permissions et subagents — documenté surcode.claude.com/docs/en/agent-sdk. C’est ce qui se rapproche le plus d’un compte rendu publié de chaque mécanisme que ce chapitre construit à la main, et il vaut la peine de le lire à côté de votre propre implémentation pour les parties qu’il nomme et que ce chapitre ne fait qu’esquisser. ↩