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





