Construire un agent capable de créer ses propres outils
Dans mes articles précédents, j’ai montré comment construire un agent qui appelle des outils prédéfinis : des fonctions que vous écrivez à l’avance, que l’agent choisit et exécute. C’est déjà une étape formidable. Mais à un moment, cette approche montre ses limites : l’agent est enfermé dans le catalogue d’outils que vous avez définis pour lui.
Si l’agent rencontre un besoin que vous n’avez pas anticipé, il est bloqué. Or, un LLM sait… écrire du code. Pourquoi ne pas laisser l’agent écrire lui-même l’outil qui lui manque, le compiler, l’enregistrer dans son catalogue, puis l’utiliser ? C’est le pattern des agents qui créent leurs propres outils, et c’est ce que je veux explorer dans cet article, avec le cycle complet en JavaScript.
C’est fascinant, mais c’est aussi une frontière dangereuse : laisser une IA écrire et exécuter du code, c’est laisser une boîte noire contrôler de l’exécution arbitraire. On verra donc autant le comment que le jusqu’où ne pas aller.
Pourquoi un agent voudrait-il créer ses propres outils ?
Prenons un cas concret. J’ai un agent qui traite des fichiers de données pour mon blog et mes projets. Un jour, je lui demande de trier une liste de fichiers par leur date d’extrait EXIF. C’est un besoin que je n’avais pas prévu : aucun de mes outils prédéfinis ne le fait.
Sans capacité de création d’outils, l’agent est coincé, ou alors il demande de l’aide. Avec la capacité de créer des outils, l’agent peut :
- Comprendre qu’il lui manque une fonction “extraire la date EXIF”.
- Écrire cette fonction en JavaScript.
- La tester (idéalement dans un sandbox).
- L’enregistrer dans son catalogue d’outils.
- L’appeler pour faire le travail.
- La réutiliser la prochaine fois que ce besoin revient.
Cette capacité transforme l’agent d’un exécutant limité en un système qui s’équipe lui-même. C’est la différence entre quelqu’un qui a un outil dans sa boîte à outils et quelqu’un qui fabrique l’outil quand il en a besoin.
La bibliothèque d’outils devient alors une mémoire persistante : une forme de compétence accumulée. Chaque problème résolu ajoute un outil au catalogue, et les problèmes futurs vont plus vite parce que les outils existent déjà.
Le cycle de vie d’un outil généré
Voici le flux complet, de l’identification du besoin jusqu’à la réutilisation. Visualisons-le avec un diagramme mermaid :
Le point crucial, c’est le bureau de validation avant le sandbox : le code généré ne doit jamais être exécuté directement sans contrôle. On va y revenir en détail dans la partie sécurité.
Le registre d’outils : la mémoire persistante
Le coeur du pattern, c’est le registre d’outils (tool registry). C’est un catalogue persistant qui associe un nom à une implémentation et à une description. Pour que l’agent puisse créer des outils, ce registre doit être dynamique : on doit pouvoir y ajouter des outils à chaud, en cours de session, sans redémarrage.
Voici une implémentation minimale en JavaScript, un simple Map avec persistance :
// tool-registry.js |
La partie intéressante : register doit accepter des outils définis par du code chargé dynamiquement. C’est ce que l’agent va produire : une description (pour que le LLM sache quand l’utiliser) et une fonction (l’implémentation).
Un outil enregistré devient visible par le LLM à la prochaine itération de la boucle de l’agent, car la liste list() est ce qu’on passe dans le prompt pour le function calling.
L’agent écrit un outil
Voyons maintenant le passage à l’acte. Quand l’agent décide qu’il a besoin d’un nouvel outil, il faut lui donner un outil spécial : create_tool. C’est cet outil qui transforme le code généré par l’agent en outil réellement utilisable.
Le flux : l’agent appelle create_tool avec une description et le code source. L’outil create_tool écrit le code dans un répertoire sandbox, le teste, et si tout passe, le charge dynamiquement et l’enregistre dans le registre.
// L'outil que l'agent utilise pour créer de nouveaux outils |
Imaginons que l’agent veuille un outil pour extraire une date EXIF. Il va générer une description comme “Extrait la date de prise de vue d’un fichier image” et un code source. Regardons ce que l’agent pourrait produire :
// extrait par l'agent, destiné à devenir un outil |
Chargement dynamique et enregistrement
Le point technique clé : comment charger ce code dynamiquement dans le processus de l’agent, sans redémarrage et en toute sécurité relative ? En Node.js moderne, c’est import() dynamique qui nous sauve.
Le flux dans createTool :
import fs from 'fs/promises'; |
L’étape 2, testInSandbox, mérite toute l’attention : c’est là que la sécurité se joue. Reprenons-la dans la partie suivante.
Attention au piège : écrire le fichier dans le répertoire sandbox puis l’importer dynamiquement revient à exécuter le code dans le processus de l’agent. C’est pourquoi le test en sandbox est indispensable avant l’import dynamique, et encore faut-il que l’import lui-même soit maîtrisé (pas de dépendance arbitraire, pas d’accès réseau).
La sécurité : le point critique
C’est ici qu’il faut être très prudent. Laisser l’agent écrire du code, c’est un pouvoir immense. Trois dangers principaux :
- Code malveillant : l’agent est manipulé (via une injection de prompt, cf. mon article threat modeling) et génère du code qui exfiltre des données ou détruit des fichiers.
- Code buggé : le code généré est incorrect et casse l’agent ou corrompt des données.
- Dépendances incontrôlées : le code importe des paquets arbitraires du réseau (supply chain attack).
La parade tient en trois couches, et j’insiste : ces trois couches sont toutes nécessaires.
Couche 1 : exécution en sandbox
Le code généré ne doit être testé que dans un environnement isolé, jamais directement dans le processus de production. Le schéma que j’utilise :
import { exec } from 'child_process'; |
Le --network=none est essentiel : un code qui essaierait d’exfiltrer des données échouerait ici faute de réseau. Le temps est borné (timeout), et l’environnement est jetable (--rm), donc aucune trace résiduelle.
Coupe 2 : validation statique
Avant même le sandbox, on analyse le code généré pour bloquer les patterns dangereux : appels à child_process, fs en écriture hors sandbox, imports arbitraires, accès à process.env.
const FORBIDDEN = [ |
Cette approche par liste noire a ses failles (elle n’attrape pas tout), mais elle bloque la grande majorité des tentatives naïves. Pour aller plus loin, on pourrait utiliser un vrai linter avec des règles de sécurité, mais l’essentiel est de ne jamais s’y fier comme unique barrière.
Couche 3 : contrôle humain
La dernière couche, la plus robuste, c’est le contrôle humain (human-in-the-loop), comme vu dans l’article sur le threat modeling. Un outil qui exécute du code généré est classé “ risque élevé “ : avant la première exécution d’un outil nouvellement créé, on demande validation humaine.
// un nouvel outil passe par une approbation humaine avant d'être actif |
La combinaison des trois couches est nécessaire : la validation statique filtre les attaques naïves, le sandbox neutralise ce qui passe, et le contrôle humain apporte le jugement final pour ce qui reste ambigu.
La bibliothèque d’outils comme mémoire persistante
Une fois que l’agent peut créer des outils, l’intérêt se démultiplie quand on les persiste. Le registre d’outils ne doit pas vivre seulement en mémoire le temps d’une session : il doit survivre au redémarrage.
On sérialise donc le registre. Chaque outil a :
- Métadonnées : nom, description, schéma des paramètres, date de création, nombre d’usages.
- Code source : le code qui a été validé et testé.
- Statut : actif, désactivé, en attente de validation.
async function persistRegistry() { |
Cette persistance crée une boucle d’apprentissage : plus l’agent travaille, plus sa bibliothèque grossit. Un besoin résolu une fois produit un outil réutilisable pour toujours. C’est pourquoi je parle de “ mémoire “ : le registre est une forme de savoir-faire accumulé, au même titre que la base vectorielle dans le RAG, mais orientée action plutôt que connaissance.
La prochaine fois qu’un besoin similaire apparaît, l’agent voit l’outil dans list() et l’utilise directement, sans repasser par la création. C’est là tout le gain.
L’auto-amélioration : l’agent devient meilleur
Une conséquence fascinante : l’agent peut améliorer ses propres outils. Un jour, l’outil “extraire date EXIF” échoue sur certains fichiers. L’agent peut :
- Constater l’échec (l’outil renvoie “date inconnue”).
- Écrire une nouvelle version plus robuste du code.
- La faire passer par la validation et le sandbox.
- Remplacer l’ancienne version dans le registre.
C’est une forme primitive d’auto-amélioration : l’agent n’apprend pas seulement par le contexte, il évolue sa propre boîte à outils.
// un outil peut être mis à jour par l'agent lui-même |
Je recommande de versionner chaque outil et de garder l’historique, à la fois pour rejouer ce qui a été fait et pour pouvoir revenir en arrière si une amélioration est en réalité une régression.
La correction du code généré : le point faible
Le code généré par un LLM n’est pas toujours correct. Il peut avoir des bugs subtils, des erreurs de logique, des oublis de cas limites. C’est pourquoi le sandbox ne teste pas seulement la sécurité : il doit tester la correction.
Le principe : fournir des échantillons de test avec les résultats attendus (exemples positifs et négatifs) et ne valider l’outil que si le code passe tous les tests.
const tests = [ |
Plus on fournit de cas de test (valeurs limites, entrées invalides), plus on a de chances que l’outil généré soit correct. C’est un équilibre : le test ne prouve pas l’absence de bugs, mais il attrape l’essentiel. C’est le même compromis que pour les tests charge k6 que je faisais sur mes services : on teste ce qui compte, on accepte une marge d’incertitude.
La limite : où ça devient dangereux
Il faut dire clairement où s’arrête la raison. Le pattern “l’agent crée ses propres outils” est puissant, mais plus on lui donne de liberté, plus on se rapproche du danger.
La limite du réseau
Si le code généré a accès au réseau, il peut exfiltrer des données vers un serveur attaquant. C’est la limite absolue : le code généré ne doit jamais avoir d’accès réseau sortant vers l’extérieur, sinon tout le reste est perdu. Le --network=none n’est pas une option, c’est une obligation.
La limite de l’exécution arbitraire
Chaque outil créé augmente la surface d’attaque. Si l’agent a créé 50 outils, chacun est un vecteur potentiel. Plus la bibliothèque grossit, plus elle doit être surveillée (audit, revue périodique des outils enregistrés).
La limite du contrôle humain
À un moment, si l’agent peut créer des outils librement, le contrôle humain devient un goulot d’étranglement, voire une illusion. Il faut donc définir des frontières claires :
- Autonomie bornée : l’agent crée librement des outils de transformation de données locales et sans réseau.
- Approbation humaine : tout outil qui touche au réseau, aux fichiers sensibles, aux commandes système, ou à d’autres systèmes.
C’est la même logique que le human-in-the-loop du threat modeling : plus l’action est risquée, plus le contrôle est strict.
Ma règle personnelle est simple : l’agent peut créer et utiliser des outils dans son bac à sable, mais toute capacité qui franchit le réseau ou touche aux ressources réelles du système reste cloisonnée. Dès que la création d’outils devient une porte vers du réseau arbitraire, j’ai franchi la ligne rouge.
Comparaison avec d’autres approches
Mettons ce pattern en perspective avec les alternatives :
| Approche | Autonomie | Réutilisation | Risque | Complexité |
|---|---|---|---|---|
| Outils prédéfinis | Faible | Élevée (codés une fois) | Faible | Faible |
| Outils créés par l’agent (ce présent) | Élevée | Élevée (persistés) | Élevé | Moyenne |
| Agent qui exécute du code arbitraire sans registre | Très élevée | Nulle (jetable) | Très élevé | Faible mais dangereux |
La force du pattern que je décris, c’est le registre : le code généré n’est pas jeté dans le vide, il devient un actif réutilisable, validé, versionné. C’est ce qui le distingue d’un agent qui se contente d’exécuter un script à la volée : ici, on construit une bibliothèque durable, avec contrôle.
Conclusion
Construire un agent capable de créer ses propres outils ouvre un nouveau chapitre de l’automatisation. L’agent ne se contente plus de choisir dans un catalogue : il fabrique ses propres pièces, les teste, les enregistre, et les réutilise. C’est la différence entre un outil et une boîte à outils qui s’étoffe par elle-même.
Le prix, c’est la sécurité. Les trois couches sont non négociables : validation statique du code, exécution en sandbox sans réseau, et contrôle humain sur les actions risquées. Sans ces garde-fous, on expose l’agent (et tout ce qu’il touche) à une exécution arbitraire incontrôlée.
Si vous voulez vous lancer, je vous conseille de commencer petit : un registre en mémoire, un outil create_tool, un sandbox avec --network=none, et un contrôle humain sur la première exécution. À partir de là, laissez l’agent résoudre un vrai besoin de transformation de données, et regardez-le fabriquer son propre outil. C’est à la fois impressionnant et, une fois les garde-fous en place, rassurant.