bots-garden/mini-mepublic Fork
⑂ feature/hooks
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP
🛟 Updated. 46087c3 · on feature/hooks · k33g · 8d ago
hooks.md · 69 lines · 8.2 KBmarkdown
Blame HistoryOpen raw

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 preToolCall s'exécute à chaque appel d'outil ; node met 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 un timeout, 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 jq soit 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 messages entier. 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 par prompt, context et action.
  • 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: ignore avec un timeout court 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 donne bash au 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: false se 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_agent ou mm --ask est un processus mm distinct 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
# 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 `preToolCall` s'exécute à chaque appel d'outil ; `node` met 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 un `timeout`, 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 `jq` soit 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 `messages` entier.** 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 par `prompt`, `context` et `action`.
- **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: ignore` avec un `timeout` court 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 donne `bash` au 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: false` se 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](yolo-and-permissions.md).
- **Interroger un autre agent.** Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` est un processus `mm` distinct 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](../reference/hooks.md) pour chaque champ, et [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md) pour en écrire un.