Quiconque a livré un produit à base d’Agents connaît cette impression : obtenir une démonstration fonctionnelle est rapide, mais en faire un outil auquel l’utilisateur se fie chaque jour — qui tourne toute la journée sur sa machine sans s’effondrer — est difficile. Et le plus difficile n’est pas de raccorder le modèle. C’est toute la couche qui l’entoure.
Cette couche porte plusieurs noms ; j’utiliserai celui d’Agent Harness. Elle se situe entre le « grand modèle » et les « fonctionnalités produit », et constitue le véritable environnement d’exécution : elle transforme une demande utilisateur en une succession de tours de conversation avec le modèle, intercale les appels d’outils, réinjecte les résultats, compacte le contexte avant qu’il ne déborde, réessaie après les incidents réseau et restaure la conversation même après un plantage du processus. Le modèle réfléchit ; le harness transforme cette réflexion en une séquence d’actions fiable.
Orkas est une application de bureau à base d’Agents qui s’exécute sur la machine de l’utilisateur, et son harness réside entièrement côté client. Cet article décrit la construction de cette couche : sa décomposition, la boucle d’exécution, l’abstraction des outils et des modèles, ainsi que la gestion de la mémoire et des sessions. Les détails du code ont été épurés et généralisés, mais la structure technique est réelle.
Les couches
Si l’on met à plat un produit à base d’Agents, on obtient à peu près les couches suivantes, empilées de bas en haut :
┌────────────────────────────────────────────────────────────────────────┐
│ Fonctions du produit (chat / Skills / connecteurs / synchronisation) │
├────────────────────────────────────────────────────────────────────────┤
│ Agent Harness (boucle d’exécution / outils / session) │
├────────────────────────────────────────────────────────────────────────┤
│ Abstraction des fournisseurs (unifier plusieurs fournisseurs de LLM) │
├────────────────────────────────────────────────────────────────────────┤
│ Infrastructure (types / erreurs / journaux / configuration) │
└────────────────────────────────────────────────────────────────────────┘Un choix décisif est intégré ici : toute l’inférence des modèles s’effectue côté client. L’application de bureau n’est pas un client léger : elle contient le harness et appelle directement le modèle. Le serveur ne gère que les comptes, la synchronisation entre appareils et la facturation ; il n’exécute aucun Agent. Cette décision a façonné presque tout le reste : les sessions sont enregistrées sur le disque local, les outils agissent directement sur le répertoire de travail de l’utilisateur et les données sensibles ne quittent jamais la machine.
Le harness se décompose en plusieurs éléments : la boucle d’exécution (runner), la session, les outils, la couche Provider et la mémoire. Examinons-les un par un.
La boucle d’exécution : un générateur en streaming
Le cœur du harness est le runner. En une phrase, son rôle est de parler au modèle encore et encore jusqu’à ce que celui-ci dise « j’ai terminé ».
Il est implémenté comme un générateur asynchrone, et ce choix compte. Une exécution d’Agent ne se résume pas à « envoyer une requête, attendre le résultat ». Beaucoup de choses surviennent entre les deux : le modèle émet des tokens, demande un outil, l’outil termine, le contexte devient assez long pour déclencher une compaction, le réseau échoue et une nouvelle tentative commence. Avec des callbacks ou de simples Promises, ces états intermédiaires sont difficiles à exposer proprement à l’appelant. Avec un générateur, ils deviennent tous un flux d’événements émis par yield :
type AgentRunEvent =
| { type: "text_delta"; text: string } // model emitting tokens
| { type: "tool_start"; name: string; input: unknown } // a tool starts executing
| { type: "tool_end"; name: string; result: string } // a tool finished
| { type: "compaction"; tokensBefore: number; tokensAfter: number } // context compacted
| { type: "retry"; attempt: number; reason: string } // error, retrying
| { type: "done"; result: AgentRunResult } // terminalL’interface s’abonne à ce flux d’événements et affiche en temps réel la sortie du modèle et l’exécution des outils. En interne, le point d’entrée sans streaming se contente de « consommer le flux jusqu’au bout et prendre le dernier done » : les deux points d’entrée partagent une implémentation unique, sans second chemin de code susceptible de diverger.
Ce qui se passe dans un tour
Déroulé, un tour ressemble à ceci :
- Ajouter le message utilisateur, éventuellement accompagné d’images, à l’historique de session.
- Assembler le prompt système en injectant les outils disponibles, l’index des Skills, etc.
- Analyser la chaîne du modèle et la résoudre en un Provider concret et un identifiant de modèle.
- Convertir tous les outils en définitions comprises par le modèle et les envoyer avec l’historique.
- Consommer le flux de réponse du modèle, en utilisant
yieldpour émettre le texte token par token tout en recueillant les appels d’outils du modèle. - À la fin du flux, examiner la raison d’arrêt du modèle :
- Si c’est
tool_use, le modèle veut appeler un outil : exécuter les outils, puis revenir à l’étape 5 pour interroger à nouveau le modèle. - Sinon, le tour est terminé : assembler le résultat, émettre
yield done, puis revenir.
Un invariant doit être respecté ici : chaque appel d’outil du modèle doit être immédiatement suivi dans l’historique du résultat d’outil correspondant. L’API du modèle impose strictement cet appariement : s’il est rompu, la requête suivante échoue ou reste bloquée. Nous y reviendrons avec l’autoréparation des sessions.
Comment un appel d’outil est acheminé en retour
Le modèle n’exécute pas lui-même les outils ; il dit seulement « j’aimerais appeler read_file avec ces arguments ». Une fois que le runner a reçu cette intention :
for (const call of toolUseBlocks) {
yield { type: "tool_start", name: call.name, input: call.input };
const tool = this.tools.get(call.name);
const ctx = { workingDir, signal, state: { sandboxEnv } };
const result = await tool.execute(call.input, ctx);
// append the result to the session as a tool-result message
session.addToolResult(call.id, result);
yield { type: "tool_end", name: call.name, result: result.content };
}Les outils s’exécutent séquentiellement, leurs résultats sont ajoutés à l’historique dans l’ordre déclaré par le modèle, puis celui-ci est interrogé à nouveau avec ces résultats. Il peut alors appeler un autre outil ou donner sa réponse finale. Cette boucle « demander → appeler → répondre → redemander » permet précisément à un Agent d’accomplir des tâches en plusieurs étapes.
Un détail mérite d’être mentionné : certains outils renvoient des images, comme des captures d’écran ou des images générées. Or, de nombreux modèles n’acceptent pas les images dans le canal des résultats d’outils. Orkas extrait donc l’image dans un message utilisateur distinct placé après le résultat de l’outil : le modèle lit d’abord « l’outil a renvoyé ce texte », puis voit l’image correspondante au tour suivant. Un petit compromis pour gérer les différences de capacités entre Providers.
Que faire lorsque le contexte va déborder
L’obstacle le plus fréquent des tâches longues est la fenêtre de contexte. Orkas n’attend pas qu’elle soit pleine : il fixe un seuil de 60%. Après chaque série d’outils, il estime la part de la fenêtre occupée par les tokens actuels ; dès qu’elle dépasse 60%, il déclenche préventivement une compaction.
La compaction demande au modèle de résumer le début de la conversation, puis remplace les anciens messages par ce résumé en ne conservant que la fin récente. Cela semble simple, mais il y a un piège : après le remplacement, la partie conservée ne doit pas commencer par un « résultat d’outil orphelin ». Aucun « résultat sans appel correspondant » ne doit subsister, sous peine de rompre à nouveau l’invariant d’appariement. La logique de compaction veille donc à couper à une frontière propre.
Un choix plus intéressant mérite une explication : pourquoi cette approche grossière consistant à « résumer tout le bloc à 60% », plutôt qu’une méthode plus fine — noter chaque message et supprimer selon l’importance, extraire une structure des résultats d’outils, maintenir un arbre de mémoire à plusieurs niveaux ? Ces méthodes paraissent excellentes dans les articles de recherche, mais nous les avons délibérément écartées pour trois raisons.
Premièrement, le cache. Le cache de prompt du modèle fonctionne par préfixe : tant que le début de l’historique ne change pas, cette portion bénéficie du cache, ce qui réduit coût et latence. Une compaction fine réécrit constamment le milieu de l’historique et détruit à répétition le préfixe mis en cache : chaque modification impose un nouveau préremplissage important. La stratégie « ne rien toucher, puis compacter une fois au seuil » garde le préfixe stable pendant la grande majorité des tours ; seule cette compaction l’invalide. Elle est bien plus favorable au cache.
Deuxièmement, la complexité. Cet invariant « chaque appel d’outil doit être apparié », sur lequel nous insistons : plus vous élaguez finement l’historique, plus vous risquez de le rompre dans un cas particulier. Un résumé global ne doit protéger qu’un seul point de coupe propre ; les occasions d’erreur sont d’un ordre de grandeur moins nombreuses. Une catégorie de cas limites en moins, c’est une catégorie d’incidents de production en moins.
Troisièmement, profiter de l’amélioration des modèles. Les fenêtres de contexte n’ont cessé de grandir ces dernières années, et les modèles gèrent de mieux en mieux les longs contextes. Consacrer aujourd’hui beaucoup d’efforts à un algorithme élaboré de compaction revient à combattre un problème qui diminue : vous risquez de finir les réglages au moment où la génération suivante double sa fenêtre, et votre complexité devient un pur fardeau. À l’inverse, confier le résumé au modèle s’améliore automatiquement avec lui : plus il sait repérer l’essentiel, meilleur est le résumé, sans changer une ligne. La complexité que le modèle peut porter à votre place n’a pas à rester à votre charge.
L’estimation des tokens cache un problème facile à manquer : le chinois. L’estimer avec les réflexes de l’anglais — environ un token pour quelques caractères — conduit à une forte sous-estimation. Orkas pondère séparément les caractères CJK ; sinon, le seuil d’une conversation entièrement en chinois est mal évalué et la compaction ne se déclenche pas au bon moment.
Erreurs et nouvelles tentatives
Avec une exécution sur la machine de l’utilisateur et une dépendance à une API de modèle externe, les erreurs sont la norme plutôt que l’exception. Le runner les classe et les traite différemment :
- Réessayables : limites de débit, délais dépassés, connexions interrompues, 5xx. Attente exponentielle avec variation aléatoire, plafonnée à 30 secondes ; s’il s’agit d’une limite de débit et que le serveur a envoyé
retry-after, respecter cette indication. - Non réessayables : par exemple les échecs d’authentification. Réessayer ne servirait à rien ; l’erreur est donc renvoyée immédiatement.
- Particulières : dépassement du contexte. Tenter d’abord une compaction, réessayer une fois ensuite, et ne renvoyer une erreur que si cela échoue encore.
Il existe une autre catégorie : « l’outil lui-même a échoué ». Cela ne fait pas échouer tout le tour : l’échec d’un outil est une information pour le modèle, qui peut tout à fait tenter une autre approche après avoir vu « cette commande a échoué ». Le harness distingue ces erreurs passagères d’outils des véritables défaillances : il n’interrompt pas le flux et ne les perd pas ; elles figurent dans les statistiques a posteriori. Ces données alimentent ensuite le mécanisme d’autoévolution, sujet du prochain article.
Le signal d’annulation externe (AbortSignal) est vérifié à chaque point clé. L’utilisateur appuie sur « arrêter » et le tour courant s’interrompt immédiatement : aucune nouvelle tentative n’est lancée.
Abstraction des outils : assez simple pour être étendue
L’interface des outils est volontairement légère :
interface AgentTool {
readonly name: string;
readonly description: string; // shown to the model
readonly inputSchema: Record<string, unknown>; // JSON Schema to constrain inputs
execute(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>;
}Un outil n’est que « un nom + une description pour le modèle + un schéma d’entrée + une fonction d’exécution ». Les outils intégrés — lecture et écriture de fichiers, commande shell, recherche et récupération web — implémentent tous cette interface. La couche de bureau y ajoute des outils adaptés au local : recherche dans la base de connaissances, génération d’images, appels de connecteurs externes. L’interface reste la même.
Grâce à cette interface légère, l’origine d’un outil importe peu au runner : intégré, défini par l’utilisateur ou chargé depuis un Skill, il relève du même type, enregistré dans un unique Map<string, AgentTool> et converti à chaque tour en définition lisible par le modèle.
Les outils à effets de bord, comme les commandes shell, passent par un exécuteur isolé : délais limites, plafonds de longueur de sortie, liste de commandes bloquées et variables d’environnement transmises séparément plutôt que par modification de l’environnement global du processus. Cette dernière méthode se propagerait à de nombreux processus enfants et pourrait facilement empêcher le démarrage dans une architecture multiprocessus comme Electron.
La couche Provider : une interface unique pour plusieurs modèles
Les préférences de modèles des utilisateurs sont très diverses et un produit ne peut pas se lier à un seul fournisseur. Sous le harness, Orkas pose une abstraction Provider qui unifie les modèles de différents fournisseurs derrière une interface unique :
interface LLMProvider {
readonly id: string;
complete(params: CompletionParams): Promise<CompletionResult>;
stream(params: CompletionParams): AsyncIterable<StreamEvent>;
validateAuth(): Promise<boolean>;
}Le runner ne parle qu’à cette interface et ignore quel fournisseur se trouve derrière. Un registre assure le routage selon la chaîne du modèle : une forme explicite provider/model est directement décomposée ; un nom de modèle seul est attribué selon son préfixe. L’authentification — clé API ou jeton OAuth — est également gérée ici, avec renouvellement automatique des jetons OAuth expirés.
Pour unifier plusieurs modèles, le vrai casse-tête n’est pas la génération de texte, mais les points où les sémantiques des fournisseurs divergent. Voici deux exemples qui nous ont posé problème.
Le premier est la préservation des blocs de réflexion entre fournisseurs. Les modèles de raisonnement émettent du contenu de « réflexion » ; certains fournisseurs le chiffrent et exigent qu’il soit renvoyé à l’identique, tandis que d’autres le représentent avec des champs différents. Si l’utilisateur passe du fournisseur A au fournisseur B en cours de conversation, la signature de ce bloc dans l’historique ne correspond plus. La solution consiste à marquer chaque message avec « le modèle qui l’a produit », afin que la couche de transformation décide de le conserver tel quel : même modèle, conservation ; modèle différent, dégradation selon les règles.
Le second est le cache de prompt. D’un tour à l’autre d’une session, le préfixe se répète beaucoup, et sa mise en cache réduit sensiblement les coûts et la latence. L’implémentation transmet l’identifiant de session comme clé de cache aux fournisseurs qui le prennent en charge, en gérant leurs limites de longueur de clé — par exemple en tronquant ou en hachant une clé trop longue.
Tout cela est un travail ingrat, mais c’est précisément cette couche qui permet au runner de faire comme s’il n’existait « qu’un seul type de modèle ».
Mémoire : deux mécanismes, chacun pour son rôle
La « mémoire » d’Orkas repose en réalité sur deux mécanismes parallèles qui résolvent deux problèmes entièrement différents. Le premier est une base de connaissances fondée sur la recherche, pour les contenus volumineux que l’on « consulte au besoin ». Le second est une mémoire intersessions, pour les quelques faits clés qu’il faut « toujours garder à l’esprit ». Beaucoup de produits les mélangent ; les séparer rend les choses bien plus claires.
Base de connaissances : recherche hybride
Le premier mécanisme cible des contenus volumineux mais seulement occasionnellement pertinents : documents de l’utilisateur, anciennes notes, connaissances métier. Il s’agit d’une base locale avec recherche vectorielle, proposée avec deux moteurs : une version légère entièrement en mémoire, pour les tests et les usages éphémères, et une version persistée dans une base locale, pour la production, avec indexation plein texte et vecteurs.
Les données suivent ce chemin :
documents → découpage aux limites de ligne (avec chevauchement) → double indexation
├─ index plein texte (mots-clés, sans coût d’embedding)
└─ index vectoriel (si un modèle d’embedding est configuré)Les fragments sont découpés aux limites de lignes avec un léger chevauchement, pour éviter de couper une unité de sens en deux. La recherche est hybride : un passage vectoriel pour la proximité sémantique et un passage par mots-clés pour les correspondances littérales, puis les deux ensembles sont fusionnés par RRF (Reciprocal Rank Fusion) :
score = Σ 1 / (k + rank_i)Plus un résultat est bien classé dans un passage, plus il contribue ; la somme des deux passages respecte la pertinence sémantique sans perdre les correspondances littérales exactes. Les poids vectoriel et lexical sont réglables, avec une priorité par défaut à la sémantique. Après fusion, les résultats sont dédupliqués par « (document, ligne de début) », en ne conservant que le meilleur par emplacement ; ceux sous un seuil sont écartés, puis les K premiers sont renvoyés.
Pourquoi ne pas se fier uniquement aux vecteurs ? Parce que la recherche vectorielle échoue souvent sur les noms propres, symboles de code et chaînes exactes : des requêtes sans particularité sémantique, mais où le texte littéral compte beaucoup. À l’inverse, les mots-clés seuls ne repèrent pas « le même sens avec une autre formulation ». Combiner les deux est un compromis très pratique entre qualité de recherche et coût.
Mémoire intersessions : garder l’utilisateur à l’esprit
La base de connaissances résout le problème du « trop grand volume à garder en tête ». Mais une autre catégorie existe : peu volumineuse, elle doit rester présente en permanence — qui est l’utilisateur, ses préférences, ce qui a été convenu la fois précédente. Ces éléments ne doivent pas dépendre de la chance d’être retrouvés par une recherche ; ils doivent être présents à chaque tour.
Orkas construit donc une couche distincte de mémoire intersessions, divisée en deux parties selon le contenu :
- Profil utilisateur : faits stables sur la personne — rôle, préférences, style de communication, pile technique.
- Notes factuelles : faits durables sur le travail — décisions, jalons, conventions du projet.
Les deux sont petites, chacune plafonnée à quelques milliers de caractères, ce qui oblige à ne garder que ce qui est réellement utile à long terme. Elles ne passent pas par la recherche : elles sont directement figées dans le prompt système au début de chaque tour. L’Agent « connaît » donc ces éléments sans devoir penser à les consulter. C’est exactement l’inverse de la base de connaissances : celle-ci est « consultée au besoin, puis retirée », tandis que la mémoire intersessions est « toujours présente, toujours visible ».
Les écritures passent par un outil de mémoire dédié, appelé par le modèle lorsqu’il estime en cours de conversation qu’un élément mérite d’être retenu à long terme. Il permet l’ajout, le remplacement d’une sous-chaîne et la suppression. La description de l’outil précise clairement quoi conserver : les corrections et préférences utilisateur sont prioritaires, les décisions et conventions durables sont enregistrées ; en revanche, l’état transitoire de la tâche, les informations ponctuelles de débogage et tout ce qui se retrouve facilement ne le sont pas. La mémoire sert aux « faits durables sur l’utilisateur et le projet », pas à « là où j’en suis cette fois-ci ».
Un détail facile à négliger est pourtant important : une analyse de sécurité précède chaque écriture. Ce contenu entre tel quel dans le prompt système et persiste longtemps entre les sessions ; c’est donc une surface d’injection durable. Chaque souvenir destiné au disque est d’abord analysé à la recherche de motifs suspects : formulations classiques d’injection de prompt (« ignorez toutes les instructions précédentes », par exemple), commandes visant à exfiltrer des clés, caractères Unicode invisibles cachés dans le texte. Toute correspondance est rejetée. Avec la déduplication et la réduction des dépassements de taille, cette couche reste utile sans devenir un risque.
Ensemble, les deux mécanismes couvrent les deux extrêmes : « volumineux mais occasionnel » et « petit mais constant ». La base de connaissances gère le premier, la mémoire intersessions le second. Ajoutez la compréhension que l’Agent a de lui-même, sujet du prochain article, et un Agent Orkas arrive avec trois types de mémoire : sur les contenus, sur l’utilisateur et sur lui-même.
Sessions : conçues pour subir un plantage et se réparer
Une session gère l’historique des messages. La version de base n’est qu’un tableau en mémoire avec élagage et compaction de l’historique. Mais tout ce qui tourne sur la machine d’un utilisateur doit supposer qu’il peut être interrompu à tout instant : fermeture de l’application, redémarrage du système, arrêt du processus par un mécanisme de surveillance. La production utilise donc une session persistante, enregistrée dans un fichier JSONL local, à raison d’un message par ligne.
Deux stratégies d’écriture sont utilisées : l’ajout d’un message est atomique ; toute réécriture du fichier entier, comme la compaction ou l’effacement, utilise « écriture d’un fichier temporaire + renommage atomique ». Ainsi, même une coupure de courant en pleine écriture ne laisse pas de demi-enregistrement corrompu.
La partie la plus intéressante est la réparation des appels d’outils orphelins. Revenons à l’invariant d’appariement : le modèle appelle un outil, le harness l’exécute, le résultat est enregistré. Interrompre l’une de ces trois étapes laisse sur le disque un orphelin, « un appel sans résultat ». Si cette session est rechargée puis envoyée telle quelle au modèle, l’API la rejette ou se bloque.
La réparation s’exécute à chaque chargement d’une session depuis le disque et est idempotente :
- Parcourir tous les messages de l’assistant et recueillir leurs identifiants d’appels d’outils.
- Chercher ensuite les résultats d’outils correspondants.
- Pour chaque appel sans résultat, en synthétiser un marqué « interrompu ».
- Au passage, aligner l’ordre des résultats sur celui des déclarations d’appels et supprimer les résultats orphelins sans appel correspondant.
Après ce passage, la session respecte nécessairement l’exigence d’appariement de l’API et peut être envoyée sans risque. Le mécanisme paraît banal, mais c’est le filet de sécurité qui empêche qu’un simple plantage bloque définitivement la conversation d’un utilisateur.
Quelques décisions qui ont compté avec le recul
En réunissant tous ces éléments, certaines décisions apparaissent particulièrement précieuses après coup.
Les générateurs comme interface principale. Streaming et absence de streaming partagent une implémentation unique, les états intermédiaires remontent naturellement et l’interface peut afficher autant de détails que souhaité. Cela a évité toute une catégorie d’incohérences qu’aurait créée une approche « d’abord sans streaming, puis ajout du streaming ».
Compacter à 60%, pas à saturation. Cela réserve de la place à la compaction elle-même, qui nécessite aussi un appel de modèle, et évite d’agir dans l’urgence.
L’invariant d’appariement traverse tout le système. Du point de coupe de compaction à l’écriture sur disque, puis à la réparation au chargement, tout ce qui touche à la session respecte la même règle. Une règle unique évite à chaque endroit d’inventer ses propres correctifs.
Le travail ingrat concentré dans la couche Provider. Toutes les difficultés entre fournisseurs — blocs de réflexion, clés de cache, différences de capacités — sont absorbées par cette couche, pour garder un runner propre au-dessus. L’ajout futur d’un fournisseur de modèles déborde à peine de cette frontière.
Pour conclure
Le harness d’Orkas ne contient aucun algorithme spectaculaire. Sa valeur tient à la décomposition de l’objectif « faire fonctionner un Agent de manière fiable en conditions réelles » en modules aux frontières nettes, chacun responsable d’une partie : le runner gère la boucle et les nouvelles tentatives, les outils les capacités, la couche Provider l’unification des modèles, la mémoire la recherche, la session la persistance et la réparation. Aucun n’est complexe isolément ; c’est leur ensemble qui soutient un outil utilisé au quotidien.
S’il faut en retenir quelque chose : faire de la boucle d’exécution un générateur en streaming simplifie beaucoup la gestion des états intermédiaires ; une fois un invariant central défini, comme « les appels d’outils doivent être appariés », il faut le respecter partout, de la compaction aux écritures disque et au chargement, sans exception ; le travail ingrat entre fournisseurs doit rester concentré dans une couche, hors de la logique métier ; et, surtout, il faut supposer que le processus sera arrêté au pire moment et écrire à l’avance la réparation prévue pour ce moment.
Le prochain article aborde une partie plus intéressante d’Orkas : comment cet Agent apprend de son usage, transforme l’expérience en Skills réutilisables et devient progressivement plus utile.