حلقه‌های فراخوانی ابزار (tool-calling) در Claude به ایجاد کدهای پیچیده و درهم‌تنیده از نوع Promise در Node.js شهرت یافته‌اند.
متد جدید Promise.withResolvers() در Node.js 22 به توسعه‌دهندگان اجازه می‌دهد الگوی پر از کدهای تکراری (boilerplate) new Promise را با یک خط کد جایگزین کنند که promise و توابع resolve/reject آن را به طور همزمان در اختیار قرار می‌دهد. نتیجه آن، فراخوانی‌های فراموش‌شده‌ی resolve کمتر، نبود هشدارهای double-reject، و یک جریان کنترل (control flow) تخت‌تر است که تست کردن و زنده نگه داشتن آن در محیط‌های serverless آسان‌تر است.

چرا الگوی قدیمی جریان کار را مختل می‌کند

وقتی یک LLM مانند Claude از یک ابزار درخواست می‌کند، پیاده‌سازی معمول در Node به این شکل است:

return new Promise((resolve, reject) => {
  // launch the tool, attach callbacks, maybe fire another async call
});

سه مشکل رایج پدید می‌آیند:

  • فراموش کردن resolve – اگر مسیر کد هرگز resolve را فراخوانی نکند، یک Lambda یا سایر هندلرهای serverless تا زمان اتمام زمان (timeout) معلق می‌مانند که باعث افزایش هزینه می‌شود.
  • رد کردن مضاعف (Double reject) – یک مسیر خطا که دو بار reject را فراخوانی می‌کند، هشدارهای "unhandled rejection" را ایجاد می‌کند که می‌تواند در حالت strict باعث کرش کردن پروسه شود.
  • تو در تو شدن عمیق (Deep nesting) – هر مرحله async، یک callback دیگر را درون سازنده (constructor) قرار می‌دهد که باعث پراکندگی منطق برنامه و شکننده شدن تست‌های واحد (unit tests) می‌شود.

تمام این مشکلات از این واقعیت ناشی می‌شوند که توابع کنترلی promise درون closure سازنده قفل شده‌اند و بقیه کد را مجبور می‌کنند تا دوباره به آن دسترسی پیدا کنند.

Promise.withResolvers() در یک خط

Node 22 یک تابع کمکی استاتیک اضافه کرده است که شیئی شامل یک promise و دو تابعی که آن را نهایی (settle) می‌کنند، برمی‌گرداند:

const { promise, resolve, reject } = Promise.withResolvers();

اکنون promise می‌تواند به هر بخشی از سیستم — یک HTTP handler، یک شنونده پایگاه داده یا یک کارگر پس‌زمینه (background worker) — واگذار شود، در حالی که فراخواننده اصلی صرفاً منتظر (await) آن promise می‌ماند. نیازی به قرار دادن کل بلوک اجرای ابزار در یک سازنده new Promise نیست.

اعمال آن بر حلقه ابزار Claude

گردش کار Claude به این صورت است:

  1. LLM یک درخواست ابزار صادر می‌کند.
  2. کد شما ابزار را اجرا می‌کند (مثلاً یک فراخوانی API یا خواندن یک فایل).
  3. نتیجه ابزار برای نوبت بعدی به Claude بازگردانده می‌شود.

با استفاده از withResolvers این حلقه به این شکل ساده می‌شود:

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
  }
}

پیاده‌سازی ابزار دیگر نیازی به قرار گرفتن در یک promise جدید ندارد؛ بلکه فقط resolve و reject را دریافت می‌کند. این کار سه حالت شکست ذکر شده در بالا را از بین می‌برد.

تنظیمات عملیاتی که همچنان اهمیت دارند

حتی با ساختار تمیزتر promise، عامل‌های (agents) دنیای واقعی با محدودیت‌های دیگری روبرو می‌شوند:

  • زمان‌بندی‌ها (Timeouts) – قطعه کد بالا یک تایمر ساده را نشان می‌دهد که اگر ابزار از یک حد آستانه فراتر رفت، آن را reject می‌کند. مدت زمان را بر اساس انتظارات SLA تنظیم کنید.
  • محدودسازی (Throttling) – وقتی سرویس زیرساختی یک خطای throttling برمی‌گرداند (مثلاً ThrottlingException در Bedrock)، آن را دریافت (catch) کرده، مکث کنید و با استفاده از روش exponential back-off مجدداً تلاش کنید. جفت resolve/reject ثابت می‌ماند و فقط منطق تلاش مجدد تغییر می‌کند.
  • هزینه Lambda – در AWS Lambda، مقدار callbackWaitsForEmptyEventLoop = false را تنظیم کنید. این کار به runtime می‌گوید که به محض بازگشت handler، تابع را خاتمه دهد، حتی اگر استریم‌ها یا سایر هندل‌های پس‌زمینه همچنان باز باشند. این کار از معطل ماندن تابع در زمانی که promise در جای دیگری در حال نهایی شدن است، جلوگیری می‌کند.

وقتی این تابع کمکی معجزه نمی‌کند

متد Promise.withResolvers() فقط در Node 22 و نسخه‌های جدیدتر در دسترس است. پروژه‌هایی که محدود به نسخه‌های قدیمی‌تر LTS هستند، باید یا از polyfill برای این الگو استفاده کنند یا به سازنده کلاسیک پایبند بمانند. polyfillها می‌توانند API را شبیه‌سازی کنند اما مزایای عملکردی بومی (native) را نخواهند داشت. علاوه بر این، این تابع کمکی باگ‌های منطقی را به طور جادویی حل نمی‌کند: توسعه‌دهندگان همچنان باید اطمینان حاصل کنند که برای هر درخواست دقیقاً یکی از resolve یا reject فراخوانی می‌شود، در غیر این صورت promise برای همیشه در حالت pending باقی می‌ماند.

آنچه باید در آینده زیر نظر داشت

  • پذیرش در فریم‌ورک‌ها – کتابخانه‌هایی که حلقه‌های عامل LLM را انتزاع می‌کنند (مثلاً wrapperهای متن‌باز Claude) در حال ارائه withResolvers به عنوان یک ویژگی اختیاری هستند. به‌روزرسانی‌هایی را که این الگو را به حالت پیش‌فرض تبدیل می‌کنند، دنبال کنید.
  • اکوسیستم Node – با انتقال سرویس‌های بیشتر به Node 22، این تابع کمکی به یک استاندارد غیررسمی (de-facto) برای هر الگوی async از نوع “fire-and-wait” تبدیل خواهد شد، و نه فقط برای عامل‌های LLM.
  • استانداردهای فراخوانی ابزار – مشخصات در حال ظهور برای فراخوانی ابزار LLM ممکن است یک قرارداد “single-promise” را تجویز کنند که کاملاً با رویکرد withResolvers همسو است.

نتیجه‌گیری: با جایگزین کردن wrapper پرحرف (verbose) new Promise با یک خط کد Promise.withResolvers()، عامل‌های مبتنی بر Claude به جریان شفاف‌تر، غافلگیری‌های زمان اجرای کمتر و کنترل دقیق‌تر بر هزینه‌های serverless دست می‌یابند — به شرطی که runtime از Node 22 پشتیبانی کند.