Les hooks — explication
De quoi s'agit-il ?
Un hook est une commande que mm lance à quatre moments d'un tour : avant que le modèle lise une question (preGeneration), avant et après chaque appel d'outil (preToolCall, postToolCall), et une fois le tour terminé (postGeneration). Il lit un objet JSON qui décrit le moment, et peut répondre par une décision : réécrire la question, ajouter du contexte, arrêter le tour, refuser un appel, remplacer ses arguments. Cela permet à un projet de poser ses propres règles autour de l'agent — ce qui ne doit jamais s'exécuter, ce qui ne doit jamais être envoyé, ce qui doit être journalisé — sans toucher au code de mm.
La conception part du ticket 36, qui explorait un système bien plus vaste. Cette page explique ce qui a été gardé, où les hooks se branchent, et ce qui a été laissé de côté.
Pourquoi des commandes externes
La première exigence du ticket était qu'on puisse écrire un hook dans n'importe quel langage. Cela exclut une interface de plugins Go (il faudrait recompiler, et le paquet plugin de Go n'existe pas sur toutes les plateformes pour lesquelles mm est construit) et un interpréteur embarqué (il imposerait un langage à tout le monde). Un processus qui lit stdin et écrit sur stdout est la seule interface que tous les langages ont.
Elle a des coûts, acceptés en connaissance de cause :
- Un lancement de processus par événement. Un hook
preToolCalls'exécute à chaque appel d'outil ;nodemet quelques dizaines de millisecondes à démarrer. Face à un modèle local qui passe des secondes sur chaque réponse, c'est négligeable. C'est aussi pourquoi chaque hook a untimeout, et pourquoi la valeur par défaut est courte (10 s) : un hook est au milieu du tour, et un hook lent se sent tout de suite. - Le JSON comme contrat. L'entrée et la réponse sont petites et plates, pour qu'un hook en shell avec
jqsoit aussi simple à écrire qu'en Python ou en Node.
On retrouve la même forme ailleurs dans l'écosystème (hooks d'éditeurs et d'agents, hooks git et leurs arguments) : qui en a écrit un reconnaît l'autre.
Où ils se branchent
Chaque front end — le mode ligne, la fenêtre, la façade ACP, -p — confie son tour à une seule fonction, engine.Generate. Et à l'intérieur, le middleware de Genkit a déjà un point d'accroche WrapTool qui voit chaque appel d'outil, intégré ou MCP, avant qu'il s'exécute. Les hooks s'exécutent à ces deux endroits :
front end ──▶ engine.Generate
├─ preGeneration (la question, avant le premier appel au modèle)
├─ modèle ⇄ outils ── WrapTool
│ ├─ preToolCall (avant l'outil, et sa demande d'autorisation)
│ └─ postToolCall (après l'outil)
└─ postGeneration (le tour est terminé, quelle qu'en soit l'issue)
Les placer là plutôt que dans chaque front end, c'est une seule implémentation, un seul jeu de tests, et aucun front end qui les oublie. Un nouveau front end a les hooks sans rien faire.
preToolCall s'exécute avant la demande d'autorisation de l'outil. Un appel refusé ne demande donc jamais rien à l'utilisateur ; et un garde continue de fonctionner avec yolo: true, c'est-à-dire justement quand il compte le plus.
Le paquet internal/hooks lance les commandes et transforme leurs réponses en décisions ; il ne sait rien de Genkit ni des front ends. internal/engine/hooks.go applique ces décisions aux messages et les montre : comme réponse, comme appel d'outil en échec, ou comme remarque — une ligne dans le terminal, une ligne grisée dans la fenêtre, les journaux de l'éditeur en ACP.
Les décisions à connaître
Une question arrêtée laisse un marqueur, pas la question. La raison évidente d'arrêter une question, c'est qu'elle contient quelque chose qui ne doit pas atteindre le modèle : un mot de passe, une clé. La garder dans l'historique l'enverrait au tour suivant. L'historique garde donc [prompt blocked by a hook: <raison>] suivi de la réponse du hook — assez pour que le modèle comprenne ce qui s'est passé, rien qu'il ne doive pas lire. Le premier essai réel de la démo a trouvé une seconde fuite : le hook d'audit sur postGeneration recevait la question bloquée et écrivait le secret dans son journal. Les hooks post* lisent désormais le marqueur, eux aussi.
Les échecs suivent onError, et la valeur par défaut est warn. Un hook qui plante est signalé, et le tour continue : un script de journalisation cassé ne doit pas priver l'utilisateur de son agent. Un garde, lui, doit déclarer onError: fail — un garde qui ne peut pas s'exécuter ne doit pas laisser passer l'appel. Les deux besoins sont opposés, d'où un choix par hook.
Un nom d'événement inconnu est une erreur. YAML ignore les clés qu'il ne connaît pas ; preToolcall: aurait été un garde qui ne s'exécute jamais, sans rien dire. Refuser de démarrer est l'échec le plus sûr.
Un appel d'outil refusé répond au modèle sous forme de texte. Comme tout échec d'outil dans mm, le refus est la sortie de l'outil : [denied by a hook: <raison>]. Le modèle peut prévenir l'utilisateur ou s'y prendre autrement, au lieu que le tour se termine sur une erreur.
postGeneration n'est pas annulé avec le tour. Un journal d'audit qui perd les tours interrompus par l'utilisateur, c'est le monde à l'envers. Le hook s'exécute aussi après une annulation, borné par son propre délai.
Des arguments d'outil remplacés ne sont pas réécrits dans l'historique. L'outil s'exécute avec les arguments du hook, mais l'historique garde ce que le modèle a demandé. La requête du modèle est le compte rendu fidèle de ce qu'il a fait ; un fichier de session qui montrerait des arguments que le modèle n'a jamais écrits tromperait celui qui le lit plus tard.
Les alternatives écartées
Le ticket 36 explorait plus que ce qui a été construit. Ce qui a été laissé de côté, et pourquoi :
- Renvoyer le tableau
messagesentier. Le premier protocole du ticket laissait un hook réécrire la conversation. C'est fragile — un hook pourrait supprimer le prompt système ou réécrire les tours passés, et chaque hook devrait faire l'aller-retour de tout l'historique — et les quatre besoins (détecter, réécrire, ajouter du contexte, arrêter) sont couverts parprompt,contextetaction. - Des priorités numériques. L'ordre de la liste YAML est l'ordre d'exécution. Un champ de priorité ajoute un second ordre à garder en tête, sans rien apporter quand la liste est courte.
- Un mode
async. Un hook de journalisation est rapide ; quand il ne l'est pas,onError: ignoreavec untimeoutcourt le borne. Un mode en arrière-plan demanderait une file d'attente, un vidage à l'arrêt et une façon de signaler les échecs tardifs. allowedPaths/allowedExtensions. Le fichier YAML lance déjà des serveurs MCP et donnebashau modèle. On confie à qui l'écrit bien plus qu'un chemin de hook ; une liste blanche ne protégerait rien.- Les hooks de cycle de vie, de session et de front end (démarrage, configuration chargée, arrêt, fenêtre ouverte, éditeur connecté). Aucun cas d'usage ne les a encore demandés. On pourra les ajouter plus tard comme nouveaux événements, sans changer le protocole.
Les liens avec le reste
- Yolo et autorisations. Les hooks et
yolo: falsese complètent : un hook décide selon une règle, sans humain ; l'autorisation demande à un humain, au cas par cas. Le hook passe en premier. Voir Le mode yolo et les permissions. - Interroger un autre agent. Un agent démarré par
/ask,ask_agentoumm --askest un processusmmdistinct avec son propre fichier de configuration : il exécute ses propres hooks, pas ceux de l'agent qui l'interroge. - La compression du contexte demande un résumé au modèle par un autre chemin que
Generate; les hooks ne s'y exécutent pas.
Voir la référence des hooks pour chaque champ, et Protéger l'agent avec des hooks pour en écrire un.
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 65 66 67 68 69 |
|