Comment le Factory est construit

Un cœur hexagonal, une topologie hub-and-spoke, un bus de messages typé, et une flotte de workers — les quatre choix structurels qui rendent le moteur extensible sans le rendre complexe.

Version 1.0 · Mis à jour

01

Cœur hexagonal

Le moteur repose sur l'Architecture Hexagonale (Cockburn — Ports & Adaptateurs). Le cœur du domaine est un hexagone : il définit ses besoins sous forme de ports (interfaces Python de type Protocol) et reste totalement indépendant de la façon dont ces besoins sont satisfaits. Chaque préoccupation externe — une plateforme de messagerie, un modèle de langage, une base de données — est un adaptateur concret qui implémente un port.

Conséquence directe : le cœur n'importe jamais aiogram, discord.py, anthropic, ni aucune bibliothèque d'E/S. Il est testable de façon isolée, échangeable à n'importe quelle frontière, et immunisé contre les changements de frameworks. Un nouveau fournisseur LLM nécessite uniquement un nouvel adaptateur ; le cœur est intact.

Cœur du domaine Guards · Routeur · Sessions sans E/S · sans frameworks Telegram Discord CLI normalize() Fournisseur LLM Base de données Bus NATS implémenter le port PORT ENTRANT PORT SORTANT Le domaine définit les ports. Les adaptateurs les implémentent. Le cœur n'importe jamais les adaptateurs. Chaque nouvelle capacité est un adaptateur sur l'hexagone — jamais un nouveau cœur.
Le cœur hexagonal définit des ports (interfaces) et ignore totalement leurs implémentations. Les adaptateurs entrants normalisent les événements de plateforme en types du domaine ; les adaptateurs sortants implémentent les ports du domaine pour chaque technologie.

Trois couches affinent le pattern. L'Architecture Clean maintient les dépendances vers l'intérieur : le domaine au centre, les cas d'usage applicatifs autour, l'infrastructure et les adaptateurs en anneau extérieur. L'Architecture Hexagonale traduit cela en ports et adaptateurs. L'extension Kernel va plus loin : la couche la plus centrale est pure — aucun import de framework, aucune E/S, aucun état global mutable — entièrement testable de façon isolée et remplaçable sans coût de migration.

Un unique Composition Root câble les implémentations concrètes aux ports. C'est le seul endroit du code où les concrets d'infrastructure sont choisis — partout ailleurs, on parle à des protocoles.

02

Topologie hub-and-spoke

Le moteur s'exécute en plusieurs processus indépendants connectés par le bus de messages. Un processus est le hub — l'unique autorité de routage. Tous les autres sont des spokes : les adaptateurs de plateforme (Telegram, Discord, CLI) et les workers de capacité, chacun connecté au hub via NATS.

Les messages entrants arrivent à un spoke-adaptateur, sont normalisés en types du domaine, et publiés sur le bus. Le hub les reçoit, détermine quel agent et quel pool doit traiter la conversation, et dispatche le tour. Les réponses reviennent sur le bus vers le spoke d'origine, qui les délivre à la plateforme.

Hub autorité de routage Adaptateur Telegram Adaptateur Discord Adaptateur CLI Pool d'agents (worker CliPool) factory.inbound.* factory.outbound.* factory.clipool.cmd
Le hub est l'unique autorité de routage. Les spokes-adaptateurs publient les messages entrants et reçoivent les réponses sortantes via le bus. Le pool d'agents (CliPool) reçoit les tours dispatchés et renvoie les résultats via le hub.

Le hub ne meurt jamais à cause d'un spoke manquant. Les échecs de résolution d'adaptateur sont interceptés par le pipeline middleware ; chaque pool est limité à 100 éléments en file ; et le hub est le seul créateur du bucket KV de readiness partagé — adaptateurs et workers l'attendent, ils ne se disputent pas pour le créer.

  • Un hub, de nombreux spokes. Processus indépendants — démarrez, redémarrez ou ajoutez des spokes sans toucher au hub.
  • Isolation par scope. Chaque scope de conversation génère un pool indépendant. Deux conversations du même utilisateur correspondent à deux pools, deux contextes indépendants.
  • La confiance est côté Hub. Les spokes envoient trust=PUBLIC. La résolution de confiance est uniquement Hub-side — les adaptateurs ne décident jamais qui peut parler.
03

Le bus de messages NATS

Toute communication inter-processus passe par NATS. Chaque sujet suit la convention factory.{domaine}.{qualificatif…} — le domaine en premier, les tokens qualifiants ensuite. L'arbre de sujets est structuré, pas plat, ce qui permet aux consommateurs de s'abonner à un plan entier avec un simple wildcard.

Trois plans organisent le bus. Chaque plan a un type NATS fixe, un contrat de durabilité, et une forme de clé :

Plan Préfixe de sujet Type Durabilité Quand l'utiliser
Messages factory.{inbound,outbound}.<plateforme>.<bot_id> Core Éphémère Routage bidirectionnel hub ↔ adaptateur du contenu utilisateur
Persistance factory.turns.> JetStream durable Au moins une fois Changements d'état en append-only (tours de conversation)
Typing / Cycle de vie factory.typing.<plateforme>.<bot_id> Core Éphémère (perte acceptable) Événements d'affichage — indicateurs de frappe, signaux de progression

La règle pour ajouter un sujet : décider d'abord de la durabilité. Garantie forte requise → JetStream → plan Persistance. Perte acceptable → Core → Messages ou Typing selon la direction. Un nouveau plan nécessite une décision d'architecture distincte — avec la justification que la durabilité, la direction et la propriété du cycle de vie diffèrent tous trois des trois plans existants.

Le bus est un contrat, pas un tuyau. Les tokens de sujet ne doivent jamais contenir de points ; bot_id est validé au démarrage. Un sujet fantôme qui contourne les règles ACL par bot est une erreur de démarrage, pas une surprise à l'exécution.

04

Contrats typés

Chaque message qui franchit une frontière de processus est un schéma typé. Deux packages partagés détiennent cette surface contractuelle — l'un pour les primitives de transport, l'autre pour les schémas du domaine.

  • roxabi-nats — le SDK de transport. Fournit NatsAdapterBase, le helper de connexion, le circuit-breaker, et les utilitaires de sérialisation. Transport pur — aucune connaissance des noms de sujets ou de la sémantique du domaine. Consommé par les satellites via un tag de version pinné.
  • roxabi-contracts — le package de schémas partagés. Fournit les modèles Pydantic, les constantes de sujets, et les doubles de test pour chaque domaine cross-processus. Les satellites importent les mêmes modèles typés que ceux contre lesquels le hub publie — un drift entre éditeur et abonné devient une erreur de type à l'import, pas un mismatch silencieux sur le fil à l'exécution.

Le pipeline de streaming suit un modèle d'événements en deux étapes. L'adaptateur LLM émet des LlmEvent (deltas de texte, appels d'outils, résultat). Le StreamProcessor dans le cœur du domaine les transforme en RenderEvent (delta de texte, résumé d'outil) — une représentation agnostique de la plateforme de ce qui doit apparaître à l'utilisateur. Chaque adaptateur sortant reçoit un flux de RenderEvent et le rend de la façon native à sa plateforme.

Adaptateur LLM émet un flux LlmEvent LlmEvent StreamProcessor Cœur du domaine agnostique de la plateforme RenderEvent Adaptateurs sortants Telegram → éditer message Discord → màj embed CLI → impression colorée même RenderEvent, rendu différent
Le StreamProcessor est la frontière entre la couche LLM et la couche de délivrance. Il est agnostique de la plateforme et testable de façon isolée — aucun SDK de plateforme dans son périmètre. Les adaptateurs sortants rendent le même flux de RenderEvent chacun à sa façon native.

Chaque enveloppe qui franchit la frontière hub–adaptateur porte un champ schema_version. Les récepteurs acceptent les versions jusqu'à leur maximum attendu et rejettent les versions strictement supérieures avec un log ERROR. Un changement de schéma nécessite un déploiement coordonné — pas de migration progressive sans porte de version.

05

Pools d'agents

Le hub ne crée jamais d'agents dynamiquement. Les agents sont des singletons immuables définis au démarrage depuis des fichiers TOML seed : un modèle, un prompt système, un ensemble d'outils, un namespace. Tout l'état mutable réside dans le pool, pas dans l'agent.

Un pool est créé au premier message qui correspond à une clé de routage — un tuple à trois champs (platform, bot_id, scope_id) qui identifie de façon unique un scope de conversation. Deux conversations du même utilisateur produisent deux clés de routage indépendantes et deux pools indépendants. L'identité de l'utilisateur pour la limitation de débit est suivie séparément et n'est jamais fusionnée dans scope_id.

  • Agent — le cerveau IA. Config immuable : modèle, prompt système, outils autorisés, plugins, namespace.
  • Binding — la correspondance d'un (bot_id) vers un (agent_name) pour un scope de conversation donné. Géré par le hub.
  • Pool — le contexte de conversation en direct pour une clé de routage. Contient la session, l'état de compaction, le scope mémoire. Évincé par LRU quand la limite de pools est atteinte.

La création de pool est sérialisée sous un verrou qui encapsule une vérification d'existence explicite plus une éviction LRU. La construction inline d'identifiants de pool est interdite — les IDs de pool sont toujours dérivés via RoutingKey.to_pool_id(), produisant une chaîne stable platform:bot_id:scope_id qui correspond aux règles ACL par bot.

Les agents sont de la configuration, pas du code. Le moteur dispatche des tours ; il ne décide pas ce que l'agent sait ni comment il raisonne. Ces choix vivent dans un fichier TOML, indépendamment de la version du moteur.

06

La flotte de workers

Le calcul de capacité est délégué à une flotte de workers. Un worker est une instance de calcul qui consomme des outils pour exécuter un job — ce n'est pas un outil, ni un fournisseur, ni l'agent lui-même. Il exécute un workerEngine : un pipeline codé et déterministe qui appelle trois types d'étapes.

  • Le harness — un tour agentique pur où le modèle décide et ses outils s'exécutent. De l'agency là où vous voulez une décision.
  • Les appels d'outils — invocations directes du plan d'outils : primitives intégrées, satellites distants, skills, sous-agents.
  • Le code interne — transformations déterministes sans modèle dans la boucle. Des garanties là où vous voulez une garantie.

Les workers sont découverts par heartbeat. Chaque satellite s'annonce sur son sujet heartbeat (factory.voice.tts.heartbeat, factory.image.heartbeat, …). Le WorkerRegistry score les workers disponibles et route les requêtes vers le meilleur candidat. En cas de timeout ou d'absence de répondeur, le registre marque le worker comme périmé et passe au candidat suivant. Les workers sont automatiquement réadmis à leur prochain heartbeat.

WorkerRegistry score · route · périmé workerEngine pipeline codé séquence déterministe le worker exécute ceci Harness le modèle décide Plan d'outils intégré + distant Code interne déterministe
Le workerEngine est un pipeline déterministe. Il appelle trois types d'étapes — le harness (tour modèle), les appels d'outils, et le code interne — composant agency et garantie. Le WorkerRegistry route les requêtes vers les workers disponibles par score, avec détection automatique des workers périmés.

La couche de transport sous les workers suit une composition à trois niveaux : NatsTransport détient toute l'E/S NATS ; WorkerPoolClient ajoute le routage, le circuit-breaker, et l'abonnement aux heartbeats ; les clients de domaine encapsulent les deux avec un codec typé. Toutes les méthodes au niveau transport retournent un type Result — aucune exception ne franchit la frontière de transport, et aucun détail interne ne fuit vers les utilisateurs ou les logs.

Le harness est un appelé du moteur, pas une variante de celui-ci. De l'agency là où vous voulez une décision ; du code là où vous voulez une garantie. La ligne entre les deux est explicite, pas émergente.