Application des règles d’agent dans le code : hooks PreToolUse, PostToolUse et Stop
Découvrez pourquoi l’autorisation des agents LLM doit être gérée via des hooks de appel d’outils déterministes, comment refuser les appels en toute sécurité, et comment envelopper un dispatcheur sans recursion.
Sources: LLM Agents, The Way Nobody Told You — Part 8: Hooks
Un提示 système indiquant que « seuls les utilisateurs premium peuvent demander un remboursement » ressemble à une règle, mais pour le modèle il s’agit d’une forte suggestion. Elle sera respectée pour presque toutes les requêtes, mais ignorée lorsqu’une demande est formulée de manière suffisamment inhabituelle. Cet article montre comment déplacer les règles vraiment importantes hors du提示 et dans des hooks : de petits fragments de code déterministe qui s’exécutent à chaque appel d’outil, accèdent au contexte fiable du côté serveur et peuvent empêcher une action avant qu’elle ne se produise. À la fin, vous saurez quel type de hook utiliser pour chaque tâche, comment refuser une demande sans perturber la conversation, et comment associer des hooks à un distributeur d’outils existant en toute sécurité.
Comment une règle de提示 échoue dans la pratique
Imaginez un agent d’assistance client accompagné d’un spécialiste des remboursements. Les instructions indiquent que seuls les clients premium peuvent ouvrir des demandes de remboursement, et que les clients en version gratuite doivent être informés poliment que les remboursements ne font pas partie de leur forfait. Les tests se passent bien : les utilisateurs gratuits sont refusés, tandis que les utilisateurs premium reçoivent un dossier de demande.
Puis un utilisateur en version gratuite envoie une plainte formulée de manière inhabituelle. Elle ressemble à une demande de remboursement, mais concerne en réalité un autre type de crédit sur le compte. Le modèle, conçu pour être utile mais désormais incertain quant à la formulation de la demande, juge que la situation est suffisamment similaire et crée néanmoins le dossier de demande. Rien dans les règles n’était ambigu pour un être humain, mais un client qui n’a jamais eu le droit d’ouvrir de demandes dispose désormais enfin d’un dossier.
C’est la catégorie d’hooks de bogues qui existent pour être éliminés. Le modèle n’est pas mauvais pour suivre les instructions ; il est probabiliste. Une règle valable pour 99 % des entrées échouera sur une partie du reste de 1 %, et en production cette partie se manifeste. Lorsque la violation d’une règle a un coût réel, cette règle doit être appliquée par du code qui s’exécute à chaque fois et avec lequel le modèle ne peut pas discuter.
Trois points d’hook dans le cycle de vie des appels d’outil
Les hooks s’attachent à des moments spécifiques du cycle de vie d’un appel d’outil. Il y en a trois, chacun ayant une fonction distincte :
- PreToolUse s’exécute avant que l’outil ne soit lancé. Il peut refuser l’appel, réécrire ses entrées ou simplement l’observer.
- PostToolUse s’exécute après que l’outil ait été exécuté. Il ne peut que surveiller : enregistrer, auditer ou notifier. Il ne peut pas annuler ce qui s’est déjà produit.
Une façon concise de s’en souvenir est que PreToolUse demande si une action doit avoir lieu, PostToolUse enregistre qu’elle a bien eu lieu, et Stop marque la fin de la session.
Ces rôles ne sont pas interchangeables. L’autorisation relève de PreToolUse, car une vérification effectuée dans PostToolUse a lieu après que l’action a déjà produit ses effets. L’enregistrement des audits relève également de PostToolUse, car une entrée écrite dans PreToolUse ne consigne que l’intention ; la demande pourrait encore être refusée ou échouer, de sorte que le journal décrirait des événements qui ne se sont jamais produits. Associez chaque problématique au hook dont le moment d’exécution convient.
La capacité, l’autorisation et la politique représentent des vérifications distinctes
Supposons que vous souhaitiez ajouter trois nouvelles règles à l’outil de gestion des dossiers :
- Règle A : un dossier de cas doit contenir une liste non vide d’éléments de preuve.
- Règle B : les clients du plan gratuit ne sont pas autorisés à ouvrir de dossiers de cas.
- Règle C : la création de dossiers est bloquée de 2h du matin à 4h du matin pendant l’exécution du déploiement hebdomadaire.
Ces règles rejettent chacune certaines requêtes d’outil, mais elles se trouvent dans trois endroits différents. Il est utile de classer chaque règle dans l’une des quatre catégories suivantes :
- Capacité, c’est-à-dire la cohérence interne des entrées, relève de l’implémentation de l’outil. « La liste des éléments de preuve doit être non vide » est une vérification de capacité.
- Autorisation, c’est-à-dire la possibilité pour l’utilisateur de réaliser cette action en ce moment, relève d’un hook PreToolUse. « Les utilisateurs gratuits ne peuvent pas créer de dossiers de cas » relève de l’autorisation.
Un raccourci utile : l’outil demande s’il peut effectuer l’opération avec les arguments qu’il a reçus, tandis que le hook demande si l’appelant a la permission de le faire à ce moment-là.
Pourquoi cette séparation découle de ce que chaque couche peut voir
Cette distinction n’est pas stylistique ; elle provient des entrées dont dispose chaque couche. Un outil ne reçoit que les arguments générés par le modèle. Il peut comparer ces arguments entre eux, vérifier qu’une liste n’est pas vide ou qu’une quantité est positive, mais il n’a rien d’autre avec quoi les comparer.
Un hook reçoit les mêmes arguments ainsi que le contexte fiable compilé par le serveur : l’ID de l’utilisateur authentifié, son niveau réel, l’heure actuelle et l’ID de session. Ce contexte est stocké dans un objet HookContext créé au démarrage de la session puis figé par la suite.
Avec ces éléments en main, un hook peut raisonner de la manière suivante : le modèle souhaite appeler lookup_user avec user_id='u_999', mais la session appartient à u_004, donc l’appel est refusé. L’outil ne pourrait jamais prendre cette décision car il ne voit jamais à qui appartient la session. L’autorisation signifie toujours comparer la sortie du modèle non fiable avec l’état fiable du serveur, et seuls les hooks disposent des deux.
Séparer le contexte fiable de l’entrée non fiable
HookContext représente un état côté serveur. Votre propre code le crée au début de la session, à partir de sources que vous contrôlez telles que la couche d’authentification, le gestionnaire de session et la base de données. Il reste immuable tout au long de la durée de vie de la session, et ni l’utilisateur ni le modèle ne peuvent y avoir d’influence. La classe dataclass congelée ci-dessous capture l’essentiel dont vous avez besoin :
@dataclass(frozen=True)
class HookContext:
user_id: str # set from your auth layer
tier: Literal["free", "premium"]
session_id: str
frozen=True joue ici un rôle crucial : une fois créé, l’objet ne peut être modifié par aucun chemin de code, y compris par un crochet contenant une erreur.
tool_input, en revanche, est le dictionnaire que le modèle place dans son bloc tool_use. Traitez-le exactement comme le corps d’une requête HTTP entrante. Un utilisateur peut amener le modèle à produire presque n’importe quoi grâce à l’injection de prompts ou simplement par persistance.
Vérification de l’autorisation : on compare les deux valeurs. L’user_id fourni par le modèle est-il égal à HookContext.user_id ? La condition HookContext.tier == "free" exclut-elle cet outil d’écriture ? Le principe qui assure la sécurité du système est simple : la décision de sécurité doit reposer sur HookContext, et jamais sur quoi que ce soit fourni par le modèle.
Refuser une requête sans interrompre la conversation
La manière évidente de refuser une requête est de l’ignorer. Cependant, avec des API de appel d’outils suivant le format de message d’Anthropic, cela ne fonctionne pas. Chaque bloc tool_use émis par l’assistant doit être suivi d’un bloc tool_result contenant le même tool_use_id dans le message suivant de l’utilisateur. Si l’un d’eux reste sans réponse, la requête API suivante échoue avec une erreur 400.
Ainsi, un refus de PreToolUse doit produire un résultat synthétique qui remplace la sortie de l’outil. L’exemple ci-dessous est un dictionnaire Python (notez True) marqué comme erreur, accompagné d’un message expliquant pourquoi l’appel a été refusé et ce qu’il convient de faire à la place :
{
"type": "tool_result",
"tool_use_id": "toolu_01ABC",
"is_error": True,
"content": "BLOCKED: free-tier users cannot call prepare_case_file. Suggest upgrade or route to general FAQ."
}
Cela satisfait le contrat API, et le modèle le traite comme n’importe quelle autre observation. Il constate que la demande a été refusée pour une raison liée au niveau d’accès, s’excuse et propose une autre solution, permettant ainsi à la conversation de se poursuivre normalement.
Deux détails contribuent à ce que cela fonctionne bien.
Un préfixe cohérent transforme les refus en signal
Le préfixe BLOCKED: n’est pas une exigence de l’API ; il s’agit d’une convention. Un marqueur cohérent aide le modèle à reconnaître un refus délibéré plutôt qu’une panne temporaire, ce qui le pousse à changer de stratégie au lieu de réessayer la même requête. Ce même marqueur permet par la suite à l’outil d’évaluation de distinguer les refus intentionnels des véritables erreurs du outil. Une convention en texte brut devient ainsi un signal lisible par la machine.
Le message de refus est une instruction
Le modèle construira sa prochaine réponse à partir du texte que vous lui retournez ; écrivez-le donc avec la même attention qu’une instruction. Un simple "Denied" ne lui fournit aucun élément de travail. Un message indiquant la raison du refus (le plan gratuit ne permet pas d’utiliser cet outil) et proposant des alternatives (un passage de niveau ou une référence à la FAQ générale) lui explique pourquoi la requête a échoué et ce qu’il peut proposer à la place.
Attacher des fonctionnalités en enveloppant le dispatcheur
La plupart des agents disposent déjà d’un dispatcheur doté d’une fonction call_tool qui prend un bloc tool_use et invoque la fonction Python correspondante. La manière la moins intrusive d’ajouter des hooks consiste à envelopper cette fonction plutôt que de réécrire le dispatcheur. La première étape consiste à conserver une référence à l’original avant de modifier quoi que ce soit :
original_call_tool = dispatcher.call_tool # save the reference FIRST
Ensuite, on définit un enveloppeur qui exécute la chaîne de hooks autour de l’original sauvegardé et l’installe à la place de l’ancienne fonction. Dans le fragment publié, les lignes return result et la réaffectation dispatcher.call_tool = hooked_call_tool ont été fusionnées en une seule ligne ; il s’agit en réalité de deux instructions distinctes, la réaffectation se trouvant en dehors du corps de la fonction.
def hooked_call_tool(name, args):
event = build_event(name, args)
if denial := registry.run_pre(event):
return synthetic_error_result(denial) # PreToolUse denied
result = original_call_tool(name, args) # call the saved original
registry.run_post(event, result)
return resultdispatcher.call_tool = hooked_call_tool # NOW replace it
L’enveloppeur effectue cinq étapes :
- Transformer l’appel à l’outil en événement hook.
- Exécuter chaque hook PreToolUse.
Enregistrer avant de remplacer
Le principe à retenir est d’enregistrer l’original avant de le remplacer, et cela s’applique également aux décorateurs, au middleware et aux intercepteurs. Imaginez ce qui se passerait si vous omettiez la référence enregistrée et écriviez dispatcher.call_tool = lambda *a, **kw: dispatcher.call_tool(*a, **kw). La fonction lambda ne cherche dispatcher.call_tool que lorsqu’elle est exécutée, et à ce moment-là cet attribut pointe vers la lambda elle-même. Le wrapper s’appelle lui-même, ce qui le pousse à s’appeler encore une fois, jusqu’à ce que la première invocation de l’outil entraîne une récursion infinie et un débordement de pile.
Attribuer d’abord l’original à une variable signifie que la clôture conserve une référence vers l’objet fonction réel plutôt qu’un nom qui sera rebondi plus tard. Le wrapper atteint donc toujours l’implémentation véritable.
Pourquoi les hooks surpassent les règles de prompt et les listes d’outils statiques
Il existe trois manières courantes d’empêcher un agent de faire quelque chose, et chacune échoue différemment :
- Une instruction de prompt telle que « ne faites pas X » peut simplement être ignorée lorsque le modèle est incertain.
- Le filtrage des outils, qui consiste à en exclure complètement l’outil de la liste, est statique et ne peut pas réagir à l’état en temps de exécution.
- Les hooks sont déterministes et prennent en compte l’état en temps de exécution.
Tool filtering is still valuable. If the free-tier specialist never needs create_jira_ticket, do not give it that tool. A model cannot emit a tool_use block for a tool it has never been told about, and an ability the model lacks is more reliable than an instruction it might disregard.
Filtering falls short for rules that depend on the session. Whether the user is free-tier depends on who is logged in: the agent code is identical, but different sessions are allowed different actions. A static tool list cannot express that; a hook can.
The robust arrangement layers all three:
- Tool filtering at the specialist level, for tools a specialist never needs.
- Prompt guidance for soft preferences and general nudges.
- Hooks for runtime rules that must hold without exception.
Pour une approche similaire au sein d’un framework, consultez la manière dont les garde-fous et le middleware de LangChain gèrent ce même problème.
Une seule interface d’outils signifie une seule couche de contrôle
Les hooks offrent le meilleur résultat lorsque chaque action que l’agent peut effectuer est modélisée comme une appel d’outil : délégation à des sous-agents, requêtes vers des API externes, consultations de bases de données et recherches dans une base de connaissances. L’encadrement hooked_call_tool prend alors en compte tous ces outils, y compris ceux ajoutés ultérieurement, et l’autorisation, les politiques et l’audit s’appliquent de manière uniforme à toute l’interface d’actions.
Si la délégation à un sous-agent était mise en œuvre via une appel de fonction direct, la couche des hooks ne le verrait jamais. Il faudrait alors un deuxième mécanisme d’autorisation uniquement pour le routage, ce qui implique deux systèmes à maintenir et deux endroits où les bugs pourraient se cacher. Concevoir dès le début une interface d’outil uniforme rend les hooks un ajout mineur ; l’intégrer ultérieurement en fait une refonte importante.
Comportement d’une chaîne de hooks PreToolUse
Les systèmes réels accumulent plusieurs hooks PreToolUse, tels qu’une vérification de niveau, une vérification de fenêtre de déploiement, une correspondance avec l’ID utilisateur et une limite de dépense. Ils s’exécutent séquentiellement selon ces règles :
- La première réjection l’emporte. Elle court-circuite la chaîne, de sorte que les hooks suivants ne s’exécutent pas, tout comme l’outil lui-même.
- Une autorisation ne court-circuite pas la chaîne. Chaque hook doit être validé pour que l’appel puisse continuer.
Ordonnez la chaîne en fonction du coût et de la criticité, en plaçant en premier les vérifications peu coûteuses mais décisives. Vérifier si l’utilisateur est dans le plan gratuit nécessite de lire un seul champ ; vérifier la limite de dépenses hebdomadaires d’une organisation peut exiger une requête dans la base de données. Effectuer d’abord cette vérification peu coûteuse évite des traitements inutiles pour des appels qui seront de toute façon refusés, tout comme placer les conditions les plus sélectives en premier dans une clause WHERE SQL.
Que se passe-t-il lorsque un crochet tombe en panne ?
Chaque type de crochet nécessite une politique de gestion des pannes différente, et une erreur dans ce domaine crée de véritables bugs plutôt que des problèmes de style :
- Les erreurs liées à PreToolUse doivent être propagées. Un crochet de politique en panne constitue un défaut. Prétendre que la vérification a eu lieu alors qu’elle ne l’a pas est pire que de l’arrêter ; il faut donc arrêter l’agent jusqu’à ce que la politique soit corrigée.
- Les erreurs PostToolUse doivent être ignorées. Un enregistreur d’audit défaillant ne doit pas faire échouer la demande de l’utilisateur, ce dernier étant plus important à cet instant que l’entrée dans le journal. Enregistrez l’échec sur un canal distinct et continuez.
- Les erreurs de fermeture doivent être ignorées. La session se termine de toute façon, il est donc préférable d’enregistrer l’échec du nettoyage avant de quitter.
Un crochet PreToolUse qui capture silencieusement ses propres exceptions constitue une faille de sécurité : la vérification n’a pas eu lieu, l’appel a été exécuté et le journal d’audit ne fournit aucune explication. Un crochet PostToolUse qui laisse échapper ses exceptions transforme toute panne d’enregistrement en panne de l’agent.
Où se trouve le journal d’audit
Une configuration simple crée deux fichiers en écriture seule :
data/audit_log.jsonl, généré par PostToolUse pour chaque appel d’outil, contenant le nom de l’outil, les entrées, les résultats, un indicateur d’erreur et une date/heure.data/session_summaries.jsonl, créé une fois par session par l’hook Stop, et qui stocke une ligne de statistiques de haut niveau pour chaque conversation.
Le journal d’audit devient rapidement le fichier le plus précieux du projet. Intégré dès le début, il fournit des informations utiles pour la conformité, les outils d’évaluation, l’analyse des coûts, le débogage et les tableaux de bord, à partir d’une seule source fiable. En environnement de production, on enverrait généralement les mêmes données dans un stockage durable, mais la structure des données reste identique.
Points clés
- Les instructions fournies sont indicatives ; toute règle dont la violation entraîne des conséquences réelles doit être appliquée via du code.
tool_result synthétique clairement préfixé, afin que la conversation reste valide et que le modèle puisse se remettre en marche.