Architecture : une boucle, trois façades — explication
De quoi s'agit-il ?
mm est volontairement petit : une boucle d'agent, une poignée d'outils et un fichier de configuration. Ce qui mérite le regard, c'est la façon dont les pièces sont découpées pour que la même boucle puisse être pilotée depuis un terminal — ligne à ligne ou dans une fenêtre — et depuis un éditeur sans rien dupliquer.
Le point d'entrée, main.go, ne fait que du câblage : il charge les réglages, ouvre le moteur, construit la liste des outils, affiche la bannière et passe la main à l'une des trois façades. Chaque préoccupation vit dans son propre paquet sous internal/ :
| Paquet | Responsabilité |
|---|---|
config |
Tous les réglages dans une structure, avec des valeurs par défaut intégrées surchargées par le YAML et quelques variables d'environnement. |
engine |
La connexion au serveur de modèle et la génération elle-même : streaming, watchdog, nouvelle tentative sans streaming, requêtes de résumé, et le registre des fournisseurs. |
tools |
Les outils intégrés que le modèle peut appeler. |
fileedit |
L'édition de fichiers par remplacement exact, les règles derrière edit_file. |
skills |
La découverte des procédures markdown et le rendu du catalogue de read_skill. |
command |
L'unique table des commandes slash : nom, description, façades qui l'offrent, argument éventuel. Le REPL s'y branche, la façade ACP en annonce la tranche Éditeur, le sélecteur / de la fenêtre la liste. |
clipboard |
Met du texte dans le presse-papiers système : un outil natif du PATH d'abord (pbcopy, wl-copy, xclip, xsel), OSC 52 en repli, et il dit lequel a servi. Utilisé par /copy dans le REPL et la fenêtre. |
store |
Fichiers de session par projet (<dir>/<id>.json, réécriture complète, atomique) : création, sauvegarde, chargement, liste, plus récente. Utilisé par les deux façades quand sessions.enabled. |
mention |
La notation @chemin d'une question : les chemins existants deviennent des lignes [attached file: …], la même forme que le resource_link d'un éditeur. Utilisé par les deux façades. |
detector |
La détection d'une même action répétée avec le même résultat. |
compact |
La compression de l'historique en un résumé écrit par le modèle. |
spinner |
L'indicateur « toujours en cours » sur un terminal. |
ui |
Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. |
agent |
Le REPL du terminal. |
acp |
La façade Agent Client Protocol. |
tui |
La façade fenêtre unique (mm -tui) : un ui.Sink qui dessine sur tcell, une valeur de transcription, une boucle d'événements réveillée par les goroutines du moteur et des outils. |
Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans docs/diagrams/packages.drawio.
Pourquoi c'est conçu ainsi
Les outils rapportent les échecs comme du texte, jamais comme des erreurs. Une commande qui sort avec un code non nul, un fichier qui n'existe pas, une édition ambiguë : tout revient au modèle comme sortie de l'outil. C'est le modèle qui doit réagir, donc c'est lui qui doit lire le message. Une erreur Go terminerait le tour à sa place.
L'historique complet est conservé même quand un tour échoue. Genkit ne renvoie que le dernier message ; le moteur renvoie toute la conversation, appels d'outils et résultats compris. Les façades gardent cet historique après une interruption, une coupure du watchdog ou un dépassement de maxTurns, parce que les commandes déjà exécutées sont des faits que le modèle ne doit ni rejouer ni inventer à la question suivante. Seule une question restée sans aucune réponse est retirée.
La sortie est montrée avant que le modèle la voie. Dans le terminal, bash affiche les premières lignes de la sortie d'une commande avant de la renvoyer. « Montre-moi ce fichier » veut dire le montrer ; laisser le modèle le résumer a été observé laissant l'utilisateur sans rien.
Une boucle, trois façades. Le REPL du terminal, la fenêtre et la façade ACP reçoivent exactement le même moteur, le même prompt système et la même liste d'outils. Ce qui diffère, c'est la destination des événements. Dans le terminal, les outils affichent eux-mêmes leurs lignes 🛠️ et personne ne demande de permission. En mode ACP, stdout appartient à JSON-RPC, donc main.go redirige la sortie humaine vers stderr, désactive le spinner, et la façade installe un ui.Sink à chaque tour. Les outils vérifient s'il y a un puits actif : s'il y en a un, ils émettent des événements ToolStart, Allow, ToolRunning, ToolEnd au lieu d'afficher. Le paquet ui ne sait rien du protocole ; acp est le seul endroit qui traduit ces événements en notifications session/update et en demandes de permission. Le mode fenêtre rejoue le même tour : tui installe son propre ui.Sink à chaque tour, et les événements deviennent des entrées d'une transcription dessinée à l'écran — les outils n'ont donc rien eu à changer pour gagner une troisième maison, et l'invite de permission en ligne de permissions: ask est la version terminal du dialogue de l'éditeur.
Une seule table de commandes. Les commandes slash vivent dans internal/command, marquées des façades qui les offrent. Le REPL et la fenêtre lisent la même table ; la façade ACP n'annonce que la tranche Éditeur (/new, /compact) via available_commands_update, parce que l'éditeur possède le processus et annule lui-même les tours. Une commande ajoutée à un endroit est donc offerte — ou délibérément non — partout à la fois.
La fenêtre dessine depuis une valeur, sur une seule goroutine. Le moteur diffuse des fragments et les outils émettent des événements depuis leurs propres goroutines ; la fenêtre les enregistre dans une Transcript (une valeur protégée par un mutex) et poste un événement de réveil. Seule la boucle d'événements dessine, et elle redessine depuis l'état courant de la transcription à chaque image : un réveil perdu coûte une image en retard, jamais une image perdue. C'est la règle que suit la fenêtre d'agent de turbo-core, et la raison pour laquelle la fenêtre n'a besoin d'aucune bibliothèque de widgets : un peintre, une transcription, une fonction de mise en page et une table de touches suffisent.
La permission vit dans le puits, pas dans les outils. Un dialogue de permission est la principale chose qu'un éditeur ajoute et que le terminal n'a pas. Placer la politique dans le puits ACP garde les outils identiques dans les deux modes ; « toujours autoriser » est mémorisé par nom d'outil pour la session.
Les tours sont sérialisés en mode ACP. Le puits actif est global au processus et le moteur n'a jamais été conçu pour des générations concurrentes, donc un second session/prompt attend la fin du premier. Le protocole laisse ce choix à l'agent, et les éditeurs n'envoient de toute façon pas de prompts qui se chevauchent.
Le détecteur de boucle est séparé de l'historique. Il enregistre des triplets outil, entrée, sortie ; le même triplet trois fois de suite fait ajouter par l'outil une instruction de changer d'approche. Il n'est volontairement pas remis à zéro par /compact : oublier une conversation ne rend pas nouvelle une commande répétée.
Alternatives écartées
- Un binaire par façade. Écarté parce que le but de l'exercice est de prouver que la boucle est la même ; partager le câblage dans
main.goest ce qui le rend visible. - Afficher directement depuis la façade ACP. Impossible : la spécification interdit tout ce qui n'est pas message de protocole sur stdout.
- Renvoyer des erreurs Go depuis les outils. Écarté parce que cela termine le tour au lieu de laisser le modèle se rattraper.
Liens avec le reste
- Le registre de fournisseurs utilisé par le moteur est décrit dans fournisseurs.
- Pourquoi et comment l'historique est raccourci est dans compression du contexte.
- La surface exacte du protocole est dans la référence ACP ; le contrat des outils est dans la référence des outils.
Carte des paquets
Le graphe d'import de tous les paquets, avec une ligne de rôle sur chacun, est tenu sous forme de diagramme draw.io dans docs/diagrams/packages.drawio (s'ouvre dans diagrams.net ou l'extension Draw.io de VS Code). Il est regénéré à partir de go list chaque fois qu'un paquet est ajouté, retiré ou recâblé.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
|