Les boucles d'appel d'outils (tool-calling) de Claude ont la réputation de générer du code de promesses emmêlé dans Node.js.
La nouvelle méthode Promise.withResolvers() de Node.js 22 permet aux développeurs de remplacer le modèle new Promise, lourd en code répétitif (boilerplate), par une seule ligne qui fournit la promesse ainsi que ses fonctions resolve et reject. Le résultat est une diminution des appels resolve oubliés, l'absence d'avertissements de double rejet (double-reject) et un flux de contrôle plus plat, plus facile à tester et à maintenir actif dans les environnements serverless.
Pourquoi l'ancien modèle casse le flux
Lorsqu'un LLM comme Claude sollicite un outil, l'implémentation Node typique ressemble à ceci :
return new Promise((resolve, reject) => {
// launch the tool, attach callbacks, maybe fire another async call
});
Trois pièges récurrents apparaissent :
- Resolve oublié – Si le chemin de code n'appelle jamais
resolve, une fonction Lambda ou un autre gestionnaire serverless reste bloqué jusqu'à l'expiration du délai (timeout), ce qui augmente les coûts. - Double rejet – Un chemin d'erreur qui appelle
rejectdeux fois déclenche des avertissements de « rejet non géré » (unhandled rejection) qui peuvent faire planter le processus en mode strict. - Imbrication profonde – Chaque étape asynchrone imbrique un autre callback à l'intérieur du constructeur, éparpillant la logique et rendant les tests unitaires fragiles.
Tous ces problèmes découlent du fait que les fonctions de contrôle de la promesse sont verrouillées à l'intérieur de la fermeture (closure) du constructeur, forçant le reste du code à y revenir.
Promise.withResolvers() en une seule ligne
Node 22 ajoute un utilitaire statique qui renvoie un objet contenant une promesse et les deux fonctions qui permettent de la régler (settle) :
const { promise, resolve, reject } = Promise.withResolvers();
Désormais, la promesse peut être transmise à n'importe quelle partie du système — un gestionnaire HTTP, un écouteur de base de données ou un worker en arrière-plan — tandis que l'appelant d'origine se contente de faire un await sur la promesse. Il n'est plus nécessaire d'envelopper tout le bloc d'exécution de l'outil dans un constructeur new Promise.
Application à la boucle d'outils de Claude
Le flux de travail de Claude est le suivant :
- Le LLM émet une requête d'outil.
- Votre code exécute l'outil (par exemple, un appel API, une lecture de fichier).
- Le résultat de l'outil est renvoyé à Claude pour le tour suivant.
Avec withResolvers, la boucle se simplifie ainsi :
async function runTool(request) {
const { promise, resolve, reject } = Promise.withResolvers();
// Kick off the tool; it can call resolve/reject from anywhere
executeTool(request, { resolve, reject });
// Optional timeout wrapper
const timeout = setTimeout(() => reject(new Error('Tool timed out')), 10_000);
try {
const result = await promise;
clearTimeout(timeout);
return result; // feed back to Claude
} finally {
// clean-up if needed
}
}
L'implémentation de l'outil n'a plus besoin d'être enveloppée dans une nouvelle promesse ; elle reçoit simplement resolve et reject. Cela élimine les trois modes de défaillance listés ci-dessus.
Paramètres de production qui restent importants
Même avec une structure de promesse plus propre, les agents en conditions réelles rencontrent d'autres contraintes :
- Timeouts – L'extrait ci-dessus montre un minuteur simple qui rejette si l'outil dépasse un certain seuil. Ajustez la durée en fonction de vos attentes en matière de SLA.
- Throttling – Lorsqu'un service sous-jacent renvoie une erreur de limitation de débit (par exemple,
ThrottlingExceptionde Bedrock), capturez-la, faites une pause et réessayez avec un back-off exponentiel. La paire resolve/reject reste la même ; seule la logique de réessai change. - Coût Lambda – Dans AWS Lambda, définissez
callbackWaitsForEmptyEventLoop = false. Cela indique au runtime de terminer la fonction dès que le gestionnaire (handler) renvoie une réponse, même si des flux (streams) ou d'autres handles en arrière-plan sont encore ouverts. Cela empêche la fonction de rester active pendant que la promesse se règle ailleurs.
Quand le nouvel utilitaire n'est pas une solution miracle
Promise.withResolvers() n'est disponible que dans Node 22 et les versions ultérieures. Les projets limités à des versions LTS plus anciennes doivent soit utiliser un polyfill pour ce modèle, soit s'en tenir au constructeur classique. Les polyfills peuvent imiter l'API, mais ils ne bénéficieront pas des avantages de performance natifs. De plus, l'utilitaire ne résout pas magiquement les bugs logiques : les développeurs doivent toujours s'assurer qu'exactement l'un de resolve ou reject est appelé pour chaque requête, sinon la promesse restera en attente (pending) indéfiniment.
À surveiller ensuite
- Adoption par les frameworks – Les bibliothèques qui abstraient les boucles d'agents LLM (par exemple, les wrappers Claude open-source) commencent à exposer
withResolverscomme une fonctionnalité optionnelle. Surveillez les mises à jour qui feront de ce modèle le standard par défaut. - Écosystème Node – À mesure que davantage de services migrent vers Node 22, cet utilitaire deviendra un standard de fait pour tout modèle asynchrone de type « fire-and-wait », et pas seulement pour les agents LLM.
- Standards d'appel d'outils – Les spécifications émergentes pour les appels d'outils LLM pourraient prescrire un contrat de « promesse unique », ce qui s'aligne parfaitement avec l'approche
withResolvers.
À retenir : En remplaçant l'enveloppe verbeuse new Promise par une ligne unique Promise.withResolvers(), les agents basés sur Claude bénéficient d'un flux plus clair, de moins de surprises à l'exécution et d'un meilleur contrôle des coûts serverless — à condition que le runtime supporte Node 22.
