La plupart des équipes d'agents ne construisent pas un harnais. Elles en adoptent un. LangChain, LangGraph, OpenAI Agents SDK, Anthropic SDK, CrewAI, AutoGen, la boucle, les outils, la mémoire et l'orchestration sont choisis sur étagère comme une décision unique. Le harnais est un framework que vous importez. Si quelque chose à l'intérieur ne convient pas, vous le fork, vous le combattez ou vous contournez le problème.

Je pense que cette forme est erronée, et c'est la raison pour laquelle chaque équipe d'agents qui dure finit par réécrire son harnais de zéro. Le harnais n'est pas une seule chose. C'est dix ou douze choses différentes regroupées car l'écosystème environnant ne vous offre pas de moyen de les composer. Les packages Pi agent sont sur la bonne voie, mais ils restent dans le paradigme « Ajoute un autre service et intègre-le avec tous les autres ». Le moteur iii traite tous les workers de la même manière et supprime complètement la logique d'intégration. Le routeur de fournisseur, le coffre d'identifiants, le moteur de politiques, la porte d'approbation, le catalogue de modèles, le stockage de session, le suivi budgétaire, le fanout des hooks post-appel et la boucle durable de tours sont des préoccupations indépendantes. Tous sont interopérables avec votre file d'attente, serveur HTTP/API, streaming, et même les workers de navigateur. Un framework qui les livre comme un seul bloc vous vend un compromis que vous n'aviez pas à faire.
Le pari sous-jacent de iii est qu'ils ne devraient pas être un seul bloc. Il devrait y avoir un ensemble de workers sur un moteur partagé, chacun remplaçable, chacun versionné indépendamment, chacun connecté par une primitive unique : un déclencheur (iii.trigger()) que tous les autres workers utilisent aussi. Le harnais devient une pile de workers installables, et « construire le vôtre » cesse de signifier « forker un framework ». Cela signifie « échanger quelques workers ».
Cet article détaille à quoi cela ressemble concrètement. La pile complète qui anime un tour d'agent iii aujourd'hui, pourquoi chaque couche est son propre worker, et comment remplacer n'importe lequel d'entre eux.
Les 15 tâches qu'un harnais d'agent doit accomplir
Si vous réduisez un harnais d'agent de production à ses responsabilités, vous obtenez une liste qui ressemble à ceci :
- Accepter une requête de tour d'un client et la persister
- Résoudre les identifiants pour le fournisseur de modèle qui sera appelé
- Rechercher ce que le modèle choisi peut réellement faire (vision, outils, streaming, fenêtre de contexte)
- Piloter la machine à états par tour, provisionner, streamer l'assistant, exécuter les outils, orienter, démanteler
- Charger et servir les corps de compétences qui décrivent la forme de requête, les codes d'erreur et les notes d'utilisation de chaque fonction
- Assembler le prompt système, le paragraphe de mode, le préambule d'identité, le répertoire de travail et l'annexe des compétences par défaut
- Streamer les tokens vers le client au fur et à mesure que le modèle les produit
- Vérifier chaque appel d'outil (c'est juste une fonction) par rapport à une politique avant son exécution
- Mettre en pause les appels d'outil qui nécessitent une décision humaine et renvoyer la réponse au bon tour
- Suivre les dépenses LLM par espace de travail ou par agent
- Exécuter des hooks avant et après les appels d'outil (journalisation, rédaction, effets secondaires personnalisés)
- Persister la session sous forme d'arbre ramifié pour que les forks et les reprises fonctionnent
- Compacter l'historique de session lorsque la fenêtre de contexte se remplit
- Émettre un flux d'événements auquel l'interface utilisateur s'abonne
- Élément manquant dans la construction de l'entreprise de chaque agent, je vois. Transporter une trace OpenTelemetry à travers chaque étape pour pouvoir la déboguer
Chaque harnais d'agent sérieux couvre la plupart de ces points. Les chers les font tous. Les bon marché coupent des coins et reconstruisent ces coins plus tard en production. Les frameworks les regroupent en un monolithe et livrent une version de chacun. Cette dernière partie est ce qui vous coûte, car au bout d'un an, vous découvrez que le moteur de politiques que vous voulez n'est pas celui que le framework livre, et le remplacer signifie remplacer le harnais.
Le harnais iii livre chacun de ces treize jobs en tant que worker séparé sur le registre workers.iii.dev. Chacun parle le même protocole WebSocket. Chacun enregistre des fonctions et des déclencheurs sur le même bus du moteur. Chacun est ajoutable, échangeable et scriptable dans n'importe quel langage avec un SDK via iii worker add.
La pile, par worker
Voici la pile de production réelle du monorepo iii-hq/workers, avec le job de chaque worker en une ligne. L'ensemble complet est livré à github.com/iii-hq/workers/harness :

Onze workers. Un moteur. Chacun a une version publiée. Chacun est exécutable indépendamment en tant que processus autonome (pnpm dev:<worker> en dev, iii worker add <specific-worker> comme binaire de release) ou comme partie du point d'entrée composite qui les lance ensemble.
Pourquoi cela compte : chaque case de ce tableau est un endroit où quelqu'un peut vous donner un worker différent, et vous gardez le reste. Vous n'aimez pas le catalogue de modèles statique ? Branchez un worker qui enregistre models::list et lit depuis une API en direct. Vous n'aimez pas les identifiants stockés dans des fichiers ? Branchez un worker qui enregistre auth::get_token et lit depuis un gestionnaire de secrets. Vous voulez une machine à états de tour différente pour un workflow qui se ramifie différemment ? Remplacez turn-orchestrator, chaque dépendant appelle run::start et lit turn_state via le même bus, donc le reste de la pile ne change pas.
Comment la boucle s'exécute réellement
La forme d'un tour ressemble à ceci, en parcourant les workers dans l'ordre où ils se déclenchent.
Un navigateur/CLI/chat POST un tour via harness::trigger avec {session_id, message_id, payload}. Le meta-worker du harnais transmet payload à run::start. Ce saut existe pour que le wrapper de span OpenTelemetry puisse ensemencer les IDs de session et de message comme baggage, qui se propage à chaque appel iii.trigger imbriqué à travers chaque worker de la pile. L'arbre de trace de l'autre côté est un graphe connecté.
run::start atterrit sur le turn-orchestrator. Il persiste la requête de tour, ensemence le TurnStateRecord initial dans l'état iii à session/<sid>/turn_state, et retourne immédiatement. Le travail réel se produit à l'intérieur de la machine à états durable, réveillée par les publications dans la FIFO turn-step.
Les deux états terminaux sont stopped (sortie propre via finishSession()) et failed (un lancement de handler inattendu arrive ici, acquitte la file pour qu'elle cesse de réessayer, et affiche message_complete{stop_reason:'error'} plus agent_end pour que l'UI montre la raison). Le démantèlement est un port finishSession() en ligne appelé depuis tout chemin de fin de tour, pas une étape mise en file d'attente séparée.
provisioning fait trois choses. Il démarre une microVM iii-sandbox si l'exécution nécessite un environnement isolé. Il appelle directory::skills::download pour chaque espace de noms dans system_default_skills (par défaut ["iii://iii-directory/index"]) afin que iii-directory pré-cache les corps de compétences avec lesquels le tour commence. Et il assemble le prompt système en trois couches : un paragraphe de mode choisi à partir de run_request.mode (plan, ask ou agent), le préambule d'identité iii qui enseigne au modèle la convention agent_trigger et le pattern de découverte à la demande directory::skills::get, et un index ajouté des compétences par défaut avec lesquelles l'agent démarre. L'appelant peut remplacer tout le prompt en passant system_prompt sur run::start ; sinon l'orchestrateur le construit. Les schémas de fonctions proviennent du catalogue du moteur en direct.
assistant_streaming appelle provider::<name>::stream sur le worker fournisseur correspondant au champ provider de l'exécution. Le worker fournisseur récupère les identifiants via auth::get_token (auth-credentials), streame la réponse SSE du modèle dans un canal iii, et l'orchestrateur draine ce canal en émettant des événements message_update sur agent::events pour le fanout vers l'UI. La création du canal et la boucle de lecture vivent derrière une MessagePump basée sur le pull dans provider-stream.ts, donc l'état du streaming reste concentré sur les transitions.
Lorsque l'assistant renvoie des appels d'outil, la machine à états entre dans function_execute. Chaque appel d'outil passe par dispatchWithHook, le point d'étranglement unique dans l'orchestrateur. consultBefore appelle policy::check_permissions directement avec un délai de 5 secondes. Le worker de politiques (le meta-worker du harnais, dans la pile par défaut) lit iii-permissions.yaml, fait correspondre le function_id de l'appel à l'ensemble de règles, et retourne l'un des trois résultats :
allow: la répartition continue ; l'orchestrateur déclenche la fonction cible et écrit le résultatdeny: la répartition est court-circuitée avec unDenialEnvelope, le résultat devient un enregistrement de refusneeds_approval: l'appel individuel se place dans la listeawaiting_approvaldu tour. Le reste du lot continue d'être réparti. Le tour passe àfunction_awaiting_approvaluniquement lorsqu'une ou plusieurs entrées sont en attente.
Le réveil d'approbation est réactif et partagé. L'orchestrateur enregistre exactement un déclencheur d'état turn::on_approval sur le scope approvals. Lorsque la console appelle approval::resolve, le worker approval-gate écrit approvals/<sid>/<cid> = {decision, reason} dans l'état iii. Cette écriture déclenche turn::on_approval, qui avance la session affectée. function_awaiting_approval lit uniquement les décisions qui viennent d'atterrir, répartit chacune dès son arrivée (allow devient une répartition pré-approuvée, deny ou aborted devient un refus synthétique), et avance lorsque awaiting_approval[] est vide. Pas de fonctions de reprise par appel à enregistrer. Pas de rescan de démarrage pour récupérer les approbations en attente. Un seul déclencheur couvre toutes les sessions.
Échec-fermé par construction : si le worker de politiques est inaccessible ou si le délai de 5 secondes expire, consultBefore refuse l'appel avec une enveloppe gate_unavailable. Si iii::durable::publish lui-même a échoué, le fanout de hook retourne publish_failed: true et l'orchestrateur le traite comme un refus.
Quelques gains de latence découlent de cette forme. Le hook post-appel de fonction court-circuite publish_collect via un cache de présence d'abonné lorsqu'aucun abonné durable n'est enregistré pour le sujet, supprimant environ 500 ms par appel de fonction exécuté. tearing_down est intégré dans finishSession(), supprimant un saut de file durable par tour. context-compaction s'abonne à un flux dédié agent::turn_end que l'orchestrateur émet aux limites des tours, donc les réveils du compactor sont par tour plutôt que par événement. Le déclencheur d'état fanout de création de session se base uniquement sur le scope et correspond en cours de traitement, donc l'ancien RPC harness::session::is_create_event par écriture a disparu.
Après la fin du lot, steering_check décide de continuer, s'arrêter ou atteindre max_turns. Si continuer, retour à assistant_streaming. Si stop ou max, finishSession() s'exécute en ligne : émet agent_end, libère le sandbox, passe à stopped.
Tout au long de l'exécution, chaque worker participant émet des spans OTel tagués avec iii.session.id, iii.message.id et iii.function.id. Ces tags sont ce que engine::traces::group_by du moteur lit pour peupler « Group by Session » / « Group by Message » / « Group by Function » dans l'interface de traces. L'instrumentation est automatique : src/runtime/worker.ts enveloppe chaque registerFunction dans une Proxy, donc aucun code worker individuel n'a besoin de se souvenir d'ajouter des spans.
Construisez le vôtre
La partie intéressante est qu'aucun des workers ci-dessus n'est spécial. Chacun est un processus qui ouvre une WebSocket vers le moteur, enregistre quelques fonctions et déclencheurs, et s'exécute. Le contrat est le même que celui utilisé par chaque worker applicatif. Le harnais est construit sur la même primitive que votre logique métier.
Ce qui signifie que « construire votre propre harnais » se décompose en la même opération que « écrire un worker ». Vous choisissez la couche que vous voulez remplacer, vous écrivez un worker qui enregistre les mêmes fonctions sur le bus, vous faites iii worker add et le reste de la pile commence à utiliser votre worker.
Deux couches n'apparaissent pas dans le tableau des workers ci-dessus mais sont importantes pour le comportement du harnais. Les compétences sont la manière dont chaque worker annonce ce que ses fonctions font. Chaque worker peut publier une compétence à iii://<worker>/<function> que l'agent récupère via directory::skills::get avant d'appeler cette fonction pour la première fois. Le prompt système est assemblé par tour à partir d'un paragraphe de mode, du préambule d'identité iii et des corps de compétences par défaut avec lesquels l'exécution a été configurée. Les deux sont pilotés par le bus : les compétences sont servies par le worker iii-directory, le prompt système est assemblé par le turn-orchestrator. Les deux sont remplaçables.
Cinq exemples concrets.
Remplacez le catalogue de modèles par une API en direct. Écrivez un worker qui enregistre models::list, models::get, models::supports. Qu'il récupère depuis le point de terminaison du catalogue de votre fournisseur toutes les N minutes et mette en cache. Publiez-le. iii worker add your-org/dynamic-models-catalog. Arrêtez le worker models-catalog statique. L'orchestrateur de tour ne voit jamais la différence. Il appelle iii.trigger('models::list') et le moteur route vers le worker qui a enregistré cet identifiant de fonction le plus récemment.
Ajoutez un nouveau fournisseur. La forme est déjà prouvée par provider-kimi et provider-lmstudio. Chacun est un worker qui enregistre provider::<name>::stream et provider::<name>::complete, draine un flux SSE de l'API distante dans un canal iii, et écrit son utilisation du modèle dans llm-budget via budget::record. Ajouter un cinquième fournisseur, c'est écrire un dossier avec un iii.worker.yaml et un register.ts. Publiez sur le registre, ou gardez-le local. L'orchestrateur de tour choisit le fournisseur via le champ provider de l'exécution ; les nouveaux fournisseurs deviennent disponibles dès que le worker se connecte.
Servez des compétences à partir d'un entrepôt d'artefacts privé. Écrivez un worker qui enregistre directory::skills::get et directory::skills::list, alimenté par votre système de documentation interne ou un bucket S3 privé. Déconnectez ou renommez le worker iii-directory par défaut. L'amorçage de l'orchestrateur appelle directory::skills::download par espace de noms ; votre worker répond. Le pattern de l'agent « récupérer la compétence par fonction avant d'appeler une nouvelle fonction » continue de fonctionner inchangé car la forme filaire est la même.
Remplacez entièrement le prompt système. run::start accepte un champ optionnel system_prompt. Passez-le et l'orchestrateur utilise votre chaîne textuellement, en sautant l'assemblage du paragraphe de mode + préambule d'identité + annexe de compétences. Utile lorsque vous avez un actif de prompt existant que vous voulez que le harnais honore sans modification. Le téléchargement des compétences s'exécute toujours lors de l'amorçage, donc l'agent conserve la découverte à la demande directory::skills::get même avec un prompt personnalisé.
Remplacez la surface UI de la porte d'approbation. Le worker approval-gate par défaut enregistre approval::resolve. Le schéma filaire est un appel de fonction :
Le handler persiste approvals/<sid>/<cid> = {decision, reason} dans l'état iii. Le déclencheur d'état unique turn::on_approval de l'orchestrateur capture cette écriture et réveille la bonne session. Si vous voulez piloter les approbations depuis Slack plutôt que depuis la console, écrivez un worker Slack qui écoute les commandes slash /approve <id> et /deny <id>, puis appelle approval::resolve avec la bonne charge utile. L'orchestrateur ne voit jamais la différence. L'ensemble du worker approval-gate reste intact. Vous avez ajouté un nouveau worker ; vous n'avez pas remplacé l'existant.
Si vous voulez un moteur de politiques différent (OPA, Cedar, votre propre DSL), écrivez un worker qui enregistre policy::check_permissions et retourne { decision, rule_id?, matched_constraint? }. Déconnectez le worker de politiques par défaut (qui est enveloppé dans le meta-worker du harnais, donc vous désactiveriez ce handler ou exécuteriez un meta-worker allégé). Le consultBefore de l'orchestrateur ne voit pas la différence. Même délai de 5 secondes, mêmes sémantiques échec-fermé, même forme filaire.
Le but de ces exemples n'est pas les remplacements spécifiques. C'est la forme de l'opération. Chaque couche du harnais dans la pile iii est accessible via un ou deux identifiants de fonction sur le bus. Remplacer une couche, c'est écrire un worker qui enregistre ces identifiants. Le reste du système reste.
Le harnais est un curseur, pas un embranchement
Le débat classique sur les harnais se présente comme fin vs épais. La boucle fine d'Anthropic contre le DAG explicite de LangGraph. Ce cadrage suppose que vous choisissez un côté et que vous vivez avec.
Lorsque le harnais est composé de workers sur le même bus, fin vs épais n'est qu'un comptage du nombre de workers que vous installez. Un harnais fin, c'est turn-orchestrator plus provider-anthropic plus auth-credentials plus un meta-worker minimal. C'est tout. Pas d'approbations, pas de budgets, pas de moteur de politiques, pas de fanout de hooks. Exécutez n'importe quoi. Faites confiance au modèle. Utile pour les agents de recherche autonomes, les boucles expérimentales, tout ce qui est interne.
Un harnais épais, ce sont les treize workers plus context-compaction plus un worker de politiques personnalisé plus une porte d'approbation personnalisée plus une surface d'approbation intégrée à Slack plus le worker budgétaire appliquant des plafonds par espace de travail. Utile pour un agent exécutant des workflows clients où chaque appel d'outil doit être vérifiable et chaque dépense de modèle doit remonter à un tableau de bord financier.
La distance architecturale entre fin et épais n'est pas une réécriture. C'est un changement de configuration. Même protocole filaire, même forme de trace, même histoire d'observabilité. Le curseur se déplace en ajoutant ou supprimant des workers de votre config.yaml. Tout le reste tient.
Cela s'applique aussi à l'intérieur d'un seul worker. Le turn-orchestrator vient de livrer un refactoring qui a réduit sa machine à états de onze à sept états, supprimé le mécanisme turn::approval_resume::<sid>/<cid> par appel au profit d'un seul déclencheur d'état réactif turn::on_approval sur le scope approvals, et intégré tearing_down dans un port finishSession(). Chaque autre worker de la pile (approval-gate, session, llm-budget, providers, models-catalog, auth-credentials, hook-fanout, context-compaction) est resté inchangé. La forme filaire de approval::resolve n'a pas bougé. Les contrats ont tenu. C'est la propriété que la composition vous donne : une réécriture interne majeure d'un worker est un changement autonome car chaque voisin communique avec lui via des identifiants de fonction au niveau du bus.
C'est la partie que le modèle de framework ne peut pas vous donner. Un framework choisit une position sur le curseur pour vous et vous y enferme. Le modèle de worker laisse le curseur entre vos mains.
Ce que cela signifie en pratique
Si vous avez exécuté un agent sur un framework et ressenti les mêmes problèmes de limite que la plupart des équipes rencontrent à l'échelle, la réponse n'est probablement pas « réécrivons le harnais dans notre propre framework ». Le moteur de politiques ne s'étend pas comme vous en avez besoin. L'interface d'approbation est câblée dans la surface de chat du framework. Le stockage d'identifiants ne peut pas parler à votre gestionnaire de secrets. Le suivi budgétaire est dans une base de données satellite que la trace ne peut pas voir. La réponse est de passer à un substrat où le harnais est décomposé dès le départ.
Le moyen le plus rapide de ressentir l'argument est de cloner github.com/iii-hq/workers, pnpm install, pnpm build, et d'exécuter le point d'entrée composite. Vous obtiendrez le harnais complet de quatorze workers pointé vers un moteur iii. Vous pouvez désactiver n'importe quel worker en supprimant son entrée de la liste de démarrage. Vous pouvez remplacer n'importe quel worker en écrivant un remplacement qui enregistre les mêmes identifiants de fonction. Vous pouvez étendre n'importe quel worker en ajoutant un abonné à ses sujets de hooks. hook-fanout::publish_collect est le générique sur lequel chaque hook iii est construit.
La documentation se trouve sur iii.dev/docs. Le moteur est sur github.com/iii-hq/iii. Le registre des workers est sur workers.iii.dev. L'ensemble du harnais est sur github.com/iii-hq/workers/harness.
Le pari
Un harnais n'est pas une chose que vous installez. Un harnais est un ensemble de tâches que votre système doit accomplir pour qu'un agent s'exécute de manière durable, sécurisée et observable. L'ère des frameworks regroupait ces tâches ensemble car rien en dessous ne vous donnait un moyen de les composer.
Le pari de iii est qu'une seule primitive — un worker qui se connecte au moteur via WebSocket et enregistre des fonctions et des déclencheurs — est suffisamment petite pour absorber chacune de ces tâches séparément, et que la pile résultante est plus utile que n'importe quel framework car chaque couche est indépendamment remplaçable.
Vous n'adoptez pas le harnais iii. Vous installez les workers que vous voulez, écrivez ceux dont vous avez besoin, et vous vous retrouvez avec un harnais exactement adapté à votre système. Même protocole sur chaque couche. Même trace à travers chaque appel. Même iii worker add pour les parties que vous prenez du registre que pour celles que vous publiez vous-même.
Voilà à quoi ressemble « construire votre propre harnais d'agent » lorsque le substrat a la bonne forme. Choisissez les workers. Écrivez ceux qui manquent. Composez. Le harnais est la composition.
Rejoignez-nous pour construire le harnais d'agent parfait dont le monde moderne a besoin : discord.gg/iiidev
iii est open source. Commencez sur iii.dev/docs. Les workers du harnais sont sur github.com/iii-hq/workers et le moteur est sur github.com/iii-hq/iii.
— Mike Piccolo, Fondateur & CEO @iiidevs





