bots-garden/mini-mepublic Fork
⑂ feature/mm-init
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. 5b2f986 · on feature/mm-init · k33g · 7d ago
hooks.md · 86 lines · 11.8 KBmarkdown
Blame HistoryOpen raw

Les hooks — explication

De quoi s'agit-il ?

Un hook est une commande que mm lance à des moments précis : avant que le modèle lise une question (preGeneration), avant et après chaque appel d'outil (preToolCall, postToolCall), une fois le tour terminé (postGeneration), et autour de chaque relais vers un autre agent (preAsk, postAsk). 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 ; le ticket 87 a ajouté les deux événements de relais. 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.

Des hooks autour des appels d'agents

Un agent peut confier une question à un autre : le modèle appelle ask_agent, l'utilisateur tape /ask-<nom>, ou une skill lance mm --ask. L'agent appelé est un processus mm distinct avec son propre fichier : il exécute ses propres hooks. Cela couvre ce qui appartient à cet agent, quel que soit celui qui l'appelle. Cela ne couvre pas ce qui appartient au relais : ce qui peut sortir de cet agent, vers qui, et ce que cet agent fait de ce qui revient. Ces règles sont celles de l'auteur de l'agent qui appelle, et le fichier de l'agent appelé n'est peut-être même pas le sien.

Avant le ticket 87, la seule prise était preToolCall/postToolCall avec matcher: ^ask_agent$, et elle était insuffisante de quatre façons : les commandes /ask et mm --ask ne passent pas par engine.Generate, donc aucun hook ne s'exécutait pour elles ; preToolCall voyait les arguments du modèle mais pas la conversation qu'ajouterait with_context, construite à l'intérieur de l'outil ; postToolCall ne pouvait qu'ajouter du texte à une réponse déjà tronquée, jamais la remplacer ; et un échec lui arrivait sous forme de texte mêlé à la sortie.

preAsk et postAsk se branchent donc au seul endroit par lequel passe chaque relais, ask.Pool.Delegate : l'outil, les commandes et la ligne de commande l'appellent tous. preAsk voit la question et la conversation avant qu'elles soient réunies dans le prompt que reçoit l'agent appelé, et peut donc réécrire l'une ou l'autre ; postAsk voit la réponse entière et l'erreur séparément, avant que la réponse soit tronquée en sortie d'outil ou gardée dans l'historique.

Quatre décisions, prises avec l'utilisateur :

  • preAsk s'exécute après la demande d'autorisation d'ask_agent. L'utilisateur approuve ce que le modèle a demandé ; le hook filtre ou réécrit ensuite. L'ordre inverse ferait approuver à l'utilisateur une question qu'il ne voit jamais.
  • Pas d'action retry. Un hook qui relance la question démarrerait un second relais à l'intérieur du premier, avec sa propre autorisation et son propre budget. Quand une réponse ne convient pas, postAsk le dit dans context, et le modèle qui appelle décide de redemander ou non — via ask_agent, où toutes les règles s'appliquent à nouveau.
  • mm --ask exécute les hooks du fichier qu'il lit, comme les deux autres chemins. Avec un hook postAsk, il retient la réponse jusqu'à ce que les hooks aient tourné, parce que sa sortie standard est ce que lit le script appelant.
  • Pas de redirection. preAsk ne peut pas changer l'agent appelé : la demande d'autorisation et l'appel d'outil nomment l'agent choisi par le modèle, et un hook qui le remplacerait en silence rendrait les deux faux.

Une limite découle du trajet de la réponse : elle s'affiche pour l'utilisateur pendant que l'agent appelé l'écrit. postAsk change ce que l'agent qui appelle lit et retient, pas ce qui a été montré — la même règle que pour postToolCall. Une réponse remplacée est annoncée sur la ligne de notice, pour que les deux ne divergent jamais sans qu'on le voie.

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 ; ses hooks voient "asked":{"depth":<n>}. Les preAsk et postAsk de l'agent qui interroge encadrent le relais. Voir Interroger un autre agent.
  • 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, Protéger l'agent avec des hooks pour en écrire un, et Contrôler les relais vers d'autres agents avec des hooks pour preAsk et postAsk.

 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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# Les hooks — explication

## De quoi s'agit-il ?

Un hook est une commande que `mm` lance à des moments précis : avant que le modèle lise une question (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`), une fois le tour terminé (`postGeneration`), et autour de chaque relais vers un autre agent (`preAsk`, `postAsk`). 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 ; le ticket 87 a ajouté les deux événements de relais. 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.

## Des hooks autour des appels d'agents

Un agent peut confier une question à un autre : le modèle appelle `ask_agent`, l'utilisateur tape `/ask-<nom>`, ou une skill lance `mm --ask`. L'agent appelé est un processus `mm` distinct avec son propre fichier : il exécute ses propres hooks. Cela couvre ce qui appartient à *cet* agent, quel que soit celui qui l'appelle. Cela ne couvre pas ce qui appartient au *relais* : ce qui peut sortir de cet agent, vers qui, et ce que cet agent fait de ce qui revient. Ces règles sont celles de l'auteur de l'agent qui appelle, et le fichier de l'agent appelé n'est peut-être même pas le sien.

Avant le ticket 87, la seule prise était `preToolCall`/`postToolCall` avec `matcher: ^ask_agent$`, et elle était insuffisante de quatre façons : les commandes `/ask` et `mm --ask` ne passent pas par `engine.Generate`, donc aucun hook ne s'exécutait pour elles ; `preToolCall` voyait les arguments du modèle mais pas la conversation qu'ajouterait `with_context`, construite à l'intérieur de l'outil ; `postToolCall` ne pouvait qu'ajouter du texte à une réponse déjà tronquée, jamais la remplacer ; et un échec lui arrivait sous forme de texte mêlé à la sortie.

`preAsk` et `postAsk` se branchent donc au seul endroit par lequel passe chaque relais, `ask.Pool.Delegate` : l'outil, les commandes et la ligne de commande l'appellent tous. `preAsk` voit la question et la conversation **avant** qu'elles soient réunies dans le prompt que reçoit l'agent appelé, et peut donc réécrire l'une ou l'autre ; `postAsk` voit la réponse entière et l'erreur séparément, avant que la réponse soit tronquée en sortie d'outil ou gardée dans l'historique.

Quatre décisions, prises avec l'utilisateur :

- **`preAsk` s'exécute après la demande d'autorisation d'`ask_agent`.** L'utilisateur approuve ce que le modèle a demandé ; le hook filtre ou réécrit ensuite. L'ordre inverse ferait approuver à l'utilisateur une question qu'il ne voit jamais.
- **Pas d'action `retry`.** Un hook qui relance la question démarrerait un second relais à l'intérieur du premier, avec sa propre autorisation et son propre budget. Quand une réponse ne convient pas, `postAsk` le dit dans `context`, et le modèle qui appelle décide de redemander ou non — via `ask_agent`, où toutes les règles s'appliquent à nouveau.
- **`mm --ask` exécute les hooks** du fichier qu'il lit, comme les deux autres chemins. Avec un hook `postAsk`, il retient la réponse jusqu'à ce que les hooks aient tourné, parce que sa sortie standard est ce que lit le script appelant.
- **Pas de redirection.** `preAsk` ne peut pas changer l'agent appelé : la demande d'autorisation et l'appel d'outil nomment l'agent choisi par le modèle, et un hook qui le remplacerait en silence rendrait les deux faux.

Une limite découle du trajet de la réponse : elle s'affiche pour l'utilisateur pendant que l'agent appelé l'écrit. `postAsk` change ce que l'agent qui appelle lit et retient, pas ce qui a été montré — la même règle que pour `postToolCall`. Une réponse remplacée est annoncée sur la ligne de notice, pour que les deux ne divergent jamais sans qu'on le voie.

## 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 ; ses hooks voient `"asked":{"depth":<n>}`. Les `preAsk` et `postAsk` de l'agent qui interroge encadrent le relais. Voir [Interroger un autre agent](asking-another-agent.md).
- **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, [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md) pour en écrire un, et [Contrôler les relais vers d'autres agents avec des hooks](../how-to/control-agent-calls-with-hooks.md) pour `preAsk` et `postAsk`.