Retour au blog

2026-09-30 · Ingénierie · 5 min de lecture · Kouakou Ghislain Boris

useAgentTool : comment un outil naît, s'exécute et disparaît

Dans OwlLayer AI, un outil est une action métier que l'agent a le droit de déclencher : un nom, une description que lit le modèle, un schéma d'arguments, un niveau de risque et un handler. Cet article suit un outil React de sa naissance à sa disparition, et explique ce que le runtime fait à chaque étape.

Le problème du fichier d'outils central

La plupart des frameworks d'agents demandent de définir les outils dans une configuration centrale : un gros objet qui liste chaque action possible, avec schémas, descriptions et handlers.

Ce fichier vit loin des composants qu'il pilote. Quand quelqu'un modifie un bouton, il doit penser à mettre à jour la définition de l'outil. Souvent, il oublie, et l'agent continue de proposer une action que l'interface ne permet plus. Pire, le modèle voit en permanence toutes les actions de l'application, y compris celles d'écrans que l'utilisateur n'a pas ouverts.

L'outil vit dans le composant

useAgentTool déclare l'outil dans le composant qui possède l'action, à côté de l'état dont il a besoin.

tsx
import { useAgentTool } from '@owllayer/react';import { z } from 'zod'; function DeleteAccountButton({ userId }: { userId: string }) {  useAgentTool(    {      name: 'delete_account',      description: 'Permanently delete the current user account',      schema: z.object({ reason: z.string().optional() }),      risk: 'critical',    },    async ({ reason }) => {      await deleteAccount(userId, reason);      return { status: 'deleted' };    },  );   return <button onClick={() => deleteAccount(userId)}>Supprimer le compte</button>;}

Le handler lit userId directement dans les props. Le risque est critical : rien ne s'exécute sans l'accord explicite de l'utilisateur. Le composant et sa capacité IA ne peuvent plus diverger, puisqu'ils sont écrits au même endroit.

Naissance : l'enregistrement

Cycle de vie d'un outil : enregistré au montage du composant, synchronisé avec CONTEXT_UPDATE, appelé avec TOOL_CALL, retiré au démontage

Au montage du composant, le hook convertit la définition en déclaration d'outil (le schéma Zod devient un JSON Schema lisible par le modèle) et l'enregistre dans le registre du client.

Ce registre est le même ToolRegistry que celui du serveur. Les règles sont donc identiques des deux côtés :

  • un nom, un outil : réenregistrer le même nom remplace l'outil, sans le dupliquer ;
  • chaque outil appartient à un composant, ce qui permet de retirer d'un coup tous les outils d'un composant qui disparaît ;
  • une limite d'outils actifs par session, 30 par défaut. Au-delà, l'outil est refusé et le refus est signalé (événement tool.registry.limit et onError), au lieu d'échouer en silence. La limite se règle sur le serveur avec maxActiveTools, qui l'annonce au client dès la connexion.

Chaque changement du registre programme une synchronisation. Les changements d'un même rendu sont regroupés : quand une page monte dix composants, le serveur reçoit un seul CONTEXT_UPDATE avec la nouvelle liste, pas dix.

Vie : l'exécution

Quand le modèle décide d'appeler delete_account, le serveur envoie un TOOL_CALL au client. L'exécuteur du client déroule alors toujours les mêmes étapes :

  1. il retrouve l'outil dans le registre. Un outil inconnu renvoie une erreur au modèle ;
  2. il valide les arguments avec le schéma. S'ils sont invalides, le modèle reçoit Validation args "delete_account": … et peut corriger son appel. Aucune approbation n'est demandée pour un appel invalide ;
  3. il applique la politique de risque : exécution directe pour none, notification pour low, approbation pour high et critical ;
  4. il exécute votre handler avec les arguments validés, où les valeurs par défaut du schéma sont déjà appliquées, et attend la Promise qu'il renvoie ;
  5. il renvoie un TOOL_RESULT au serveur, qui le transmet au modèle.

Politique de risque : none s'exécute, low s'exécute et notifie, high et critical attendent l'approbation, un outil bloqué ne s'exécute jamais

L'ordre des étapes 2 et 3 compte. Valider avant d'approuver garantit qu'on ne demande jamais à quelqu'un d'accepter une action dont les arguments sont faux.

Deux détails rendent l'exécution fiable. Le handler est gardé dans une ref mise à jour à chaque rendu : il voit toujours les props et l'état courants, sans réenregistrer l'outil. Et si le handler fait naviguer l'application, le résultat part une fois que la nouvelle page a enregistré ses outils : le modèle continue avec les actions du nouvel écran.

Le contrat à retenir : le runtime attend seulement la Promise renvoyée par le handler. Tout travail asynchrone nécessaire au résultat doit être await ou renvoyé.

Disparition : le désenregistrement

Au démontage du composant, ses outils quittent le registre et le serveur reçoit la nouvelle liste. L'agent ne peut appeler que ce qui est réellement à l'écran.

Certaines actions doivent survivre à la navigation : navigate, open_cart, logout. On les déclare avec global: true :

tsx
useAgentTool(  { name: 'open_cart', description: 'Open the cart page', risk: 'none', global: true },  () => navigate('/cart'),);

Un outil global reste enregistré jusqu'à ce qu'on le retire explicitement. La règle pratique : si l'utilisateur ne voit plus l'objet ou l'écran, le modèle ne doit plus voir l'outil correspondant. global est réservé aux actions valables partout.

Grouper les outils : les resolvers

Quand un domaine compte plusieurs actions (le panier : voir, retirer, vider), les déclarer une par une devient verbeux. useAgentToolResolver les regroupe en un seul bloc :

tsx
useAgentToolResolver(  {    cart: {      prefix: 'cart_',      tools: {        view: { description: 'List the cart items', schema: z.object({}), risk: 'none', handler: () => cart.items },        remove: {          description: 'Remove an item from the cart',          schema: z.object({ productId: z.string() }),          risk: 'high',          handler: ({ productId }) => cart.remove(productId),        },      },    },  },  { onErrorAnyCall: (name, _args, error) => toast.error(`${name} : ${error.message}`) },);

Un resolver est un agrégateur : il préfixe les noms (cart_view, cart_remove), enregistre tous les outils sous le même composant, évite de les réenregistrer à chaque rendu et ajoute des hooks communs (onBeforeAnyCall, onAfterAnyCall, onErrorAnyCall) pour les logs ou les notifications. La validation et la politique de risque restent celles de l'exécuteur : un appel invalide est refusé avant le handler et avant les hooks, comme pour useAgentTool.

Pourquoi un registre vivant compte

Un registre statique décrit ce que votre produit pourrait faire. Un registre vivant décrit ce qu'il peut faire maintenant. Ouvrez une fenêtre, et ses actions apparaissent ; changez de page, et elles disparaissent. Le modèle travaille toujours à partir d'une image fidèle de l'interface que l'utilisateur a sous les yeux. C'est ce qui rend ses actions compréhensibles, limitées, et sûres à approuver.

Pour aller plus loin