Skip to main content
مرّر sessionId وcheckpointStore إلى AgentExecutor.execute() فيصمد التشغيل أمام تعطّل، أو إعادة تشغيل، أو إيقاف (abort)، أو موافقة بشرية تستغرق أيامًا. لا شيء هنا يعتمد على مستضيف بعينه: أي CheckpointStore (LocalStorageCheckpointStore على Node، أو مخزن Cloudflare KV الذي يجهّزه بناء cloudflare-worker، أو تنفيذك الخاص المكوَّن من ثلاث دوال) وأي ApprovalStore يعملان بالطريقة نفسها.
مع createAgent()، يكفي خيار store واحد لتجهيز كل شيء: مرّر sessionId إلى send() (أو stream()) فتُحفظ للتشغيل نقاط حفظ (checkpoints) في store.checkpoints؛ وبعد تعطّل، تُكمله agent.resume(sessionId) (وتعيد null حين لا يوجد شيء معلّق). ولا حاجة إلى AgentExecutor:
يحتفظ store.approvals بحالات التوقف بانتظار الموافقة، وتواصل agent.approvals.resolve() حفظ نقاط الحفظ للتشغيل تحت sessionId الخاص به. والجلسات تُحفظ لها نقاط حفظ أيضًا: agent.session({ id }) تحفظ نقطة حفظ لكل دورة، وagent.resume(id) (أو session.resume()) تُكمل الدورة التي انقطعت، انظر الجلسات المتينة.

ما الذي يُحفظ في نقطة الحفظ، ومتى

نقطة الحفظ هي سجل محادثة التشغيل كاملًا مع عدد خطواته، واستهلاكه، وbusinessState، وقيمة status. وتُكتب تحت sessionId: التشغيل الذي ينتهي بخطأ (خطأ من المزوّد، أو خطّاف يرمي خطأً، أو PropagatingToolError، أو تعطّل العملية) يحتفظ بآخر نقطة حفظ له بالحالة 'in-progress'.

ما الذي يحدث حين تستدعي execute() مرة أخرى بالـsessionId نفسه

يتوقف ذلك على قيمة status المخزَّنة:
  • تشغيل غير مكتمل ('in-progress': تعطّل، أو أُوقف، أو فشلت كتابة في المخزن). يُستأنف التشغيل. إذا كانت دورة النموذج الأخيرة فيه تتضمن استدعاءات أدوات بلا نتيجة، تُنفَّذ تلك الاستدعاءات بعينها أولًا، عبر مسار الأدوات المعتاد (التحقق من المعطيات، والخطّافات، وneedsApproval، وtoolConcurrency)، من دون استدعاء النموذج من جديد. ثم تستمر الحلقة بما تبقّى من ميزانية maxSteps؛ ويستمر usage من الإجماليات المحفوظة في نقطة الحفظ (والأمر نفسه في resumeAfterApproval()، انطلاقًا من اللقطة).
    • يُضاف input الجديد كرسالة مستخدم بعد نتائج تلك الأدوات. ولا يوضع أبدًا بين دورة استدعاء أدوات ونتائجها، فيبقى سجل المحادثة صالحًا لدى كل مزوّد. وهذا ما يتيح للمستخدم أن يقاطع تشغيلًا (بإيقافه) ويعيد توجيهه برسالة جديدة.
    • الـinput الذي يعيد إرسال الرسالة التي بدأ منها التشغيل (وهو التصرف الطبيعي: «أعد الطلب نفسه بعد تعطّل») يُعامَل كإعادة محاولة ولا يُضاف مرة أخرى. وinput: [] كذلك يستأنف فحسب.
  • تشغيل منتهٍ ('finished'). تستمر الجلسة كمحادثة: تبقى الرسائل المخزَّنة ويصبح input دورة المستخدم التالية. مرّر الرسالة أو الرسائل الجديدة فقط؛ وإذا أعدت إرسال السجل المخزَّن متبوعًا برسائل جديدة، يُتعرَّف على السجل ولا يُكرَّر. وتُحتسب steps وusage وtoolCalls في النتيجة من الصفر للتشغيل الجديد؛ أما messages فهي المحادثة كاملة.
  • متوقف بانتظار موافقة ('awaiting-approval'). ترمي execute() الخطأ SessionAwaitingApprovalError (مع sessionId وapprovalId) ولا تستدعي النموذج ولا أي أداة: فلا يجوز أن يتجاوز مدخل جديد قرارًا معلّقًا. احسمه أولًا بـresumeAfterApproval()، مع تمرير checkpointStore نفسه، ثم أرسل الرسالة الجديدة.
تُتجاهَل رسائل النظام في input حين يُضاف إلى جلسة مخزَّنة (فالجلسة لها موجّه النظام الخاص بها أصلًا). ولإنهاء جلسة والبدء من جديد بالمعرّف نفسه، استدعِ checkpointStore.delete(sessionId).

الموافقات في منتصف دفعة أدوات

حين تطلب دورة نموذج واحدة عدة أدوات وتحتاج إحداها إلى موافقة، تُنفَّذ الاستدعاءات التي تسبقها وتُسجَّل، ويتوقف التشغيل عندها. وتسجّل لقطة الموافقة (snapshot) الاستدعاءات التي تليها (remainingToolCalls). ثم تقوم resumeAfterApproval() بما يلي:
  1. تسجّل نتيجة الاستدعاء المتوقف: نتيجة الأداة إن وُوفق عليه، أو نتيجة رفض منظَّمة { error, note } إن رُفض؛
  2. تنفّذ الاستدعاءات المتبقية بمنطق الدفعة نفسه: فقد تُنفَّذ، أو تفشل في التحقق، أو توقف التشغيل مرة أخرى عند موافقة أخرى (approvalId جديد؛ احسمه بالطريقة نفسها، وبعدد المرات اللازم)؛
  3. تستدعي النموذج حالما يصبح لكل استدعاء في الدورة نتيجة واحدة بالضبط.
يستخدم التشغيل المستأنف approvalStore نفسه لأي موافقة لاحقة ما لم تمرّر مخزنًا آخر في الخيارات.

الضمانات

  • يحصل كل استدعاء أداة على نتيجة واحدة بالضبط، بترتيب الاستدعاء، بعد أي تتابع من حالات التعطّل والإيقاف والتوقف المؤقت والاستئناف. (الاستدعاء الذي أُوقف يحصل على نتيجة «أُلغي»؛ والاستدعاء المرفوض يحصل على نتيجة رفض.)
  • النتيجة المسجَّلة لا يُعاد تنفيذها أبدًا. تُحفظ النتائج في نقاط الحفظ كلما امتدّت سلسلة الاستدعاءات المكتملة المتتالية بترتيبها؛ والتشغيل المستأنف لا ينفّذ إلا الاستدعاءات التي ليست لها نتيجة مسجَّلة.
  • دورة النموذج المحفوظة في نقطة حفظ لا تُولَّد من جديد أبدًا. إذا توقفت العملية بعد أن أجاب النموذج، ينفّذ التشغيل المستأنف استدعاءات الأدوات الواردة في تلك الإجابة بدل سؤال النموذج مرة أخرى (فلا تكلفة إضافية ولا خطة مختلفة).
  • يتلقى المزوّد دائمًا سجل محادثة صالحًا: نتائج الأدوات تأتي مباشرة بعد دورة استدعاء الأدوات الخاصة بها، ومدخل المستخدم الجديد يأتي بعدها.

الأدوات تُنفَّذ مرة واحدة على الأقل: اجعل الآثار الجانبية آمنة عند التكرار

الأداة التي كانت قيد التنفيذ حين توقفت العملية ليست لها نتيجة مسجَّلة، فتُنفَّذ من جديد عند الاستئناف. فتنفيذ الأدوات إذن «مرة واحدة على الأقل» (at-least-once) لا «مرة واحدة بالضبط» (exactly-once). أما الأدوات ذات الآثار الجانبية (خصم مبلغ من بطاقة، أو إرسال بريد إلكتروني) فإما أن تقيّدها بـneedsApproval وإما أن تجعلها آمنة عند التكرار (idempotent). يُمرَّر toolCallId الخاص بكل استدعاء (وهو معرّف النموذج للاستدعاء، ولا يتغير حين يُعاد تنفيذه عند الاستئناف) إلى execute، فيصلح مفتاحًا لمنع التكرار (idempotency key):
(يُعيَّن toolCallId في كل تنفيذ لأداة، ومنها الأدوات التي تمرّ عبر sandboxExecute.)

السجل التاريخي لنقاط الحفظ

قيد نقطة الحفظ يُستبدل عند كل حفظ، فتزول خطوات التشغيل السابقة حالما يتقدّم. ويمكن للمخزن أيضًا أن يحتفظ بسجل تاريخي محدود لكل جلسة: كل save() يضيف القيد إلى حلقة دوّارة (أحدث historyLimit عملية حفظ، والافتراضي 50؛ وتُسقَط الأقدم)، وعلى ذلك تقوم إعادة تشغيل (replay) تشغيلٍ ما أو تفريعه (fork) من خطوة سابقة. المخازن التي تحتفظ بسجل تاريخي: مثلًا، مع المخزن الذي يعمل في الذاكرة:
  • تعيد history(sessionId, { limit? }) مدخلات بالشكل { step, savedAt, status, checkpoint }، الأحدث أولًا (step هو checkpoint.stepIndex، وsavedAt طابع زمني بصيغة ISO، وstatus يكون 'in-progress' لنقطة حفظ لا تحمل حالة). يحفظ المنفِّذ بعد كل دورة نموذج وكل دفعة أدوات، فللجلسة مدخل واحد لكل عملية حفظ؛ والمدخلات تحتوي نقاط حفظ كاملة، فاخفض historyLimit لسجلات المحادثة الطويلة. والجلسة غير المعروفة تعطي [].
  • الدالة history اختيارية في CheckpointStore: المخزن المخصَّص الذي لا يوفّرها يواصل العمل، وتعيد له getCheckpointHistory() القيمة undefined بدل أن ترمي خطأً.
  • يحتفظ KVCheckpointStore بمفتاح فهرس واحد لكل جلسة (<prefix><sessionId>#history، وفيه معرّفات المدخلات من الأقدم إلى الأحدث) وبمفتاح واحد لكل مدخل (<prefix><sessionId>#history/<id>)، فلا تكبر أي قيمة مع نمو السجل التاريخي؛ وتبقى أحدث نقطة حفظ عند <prefix><sessionId>، فالمخزن المكتوب قبل وجود السجل التاريخي يُحمَّل كما كان. عملية الحفظ تكتب نقطة الحفظ، ثم المدخل، ثم الفهرس، ثم تحذف المدخلات التي أسقطتها: فالتعطّل بين كتابتين يترك في أسوأ الأحوال مدخلًا غير مدرج في الفهرس (تنتهي صلاحيته مع TTL)، ولا يترك أبدًا مخزنًا تتعذّر قراءته. لا يدعم KV المعاملات (transactions)، فقد تفقد عمليتا حفظ متزامنتان لجلسة واحدة تحديثًا واحدًا للفهرس (فيغيب ذلك المدخل عن history())، وقد يغيب مدخل لبعض الوقت في موقع طرفي (edge) آخر (اتساق نهائي؛ وhistory() تتخطاه). وجِّه طلبات الجلسة إلى موقع واحد حين يكون ذلك مهمًا. كل عملية حفظ تكلّف عملية get واحدة وعمليتي put إضافية؛ وhistoryLimit: 0 يعطّل ذلك.
  • مخزن الملفات في Agent Forge يكتب السجل التاريخي ملفًا واحدًا لكل جلسة (.lousho/agents/<id>/checkpoint-history/<session>.json، ويُستبدل استبدالًا ذرّيًا)؛ والملف الذي أتلفه تعطّل يُقرأ كأنه بلا سجل تاريخي ويعيد الحفظ التالي بناءه.
  • يحتفظ SqliteStore بالسجل التاريخي في جدول جديد checkpoint_history، يُضاف عند فتح ملف قاعدة بيانات موجود؛ وprune() تزيل أيضًا مدخلات السجل التاريخي الأقدم من حدّها الزمني.

التفريع وإعادة التشغيل

تبدأ AgentExecutor.fork() جلسة جديدة من خطوة في السجل التاريخي لجلسة أخرى: «ماذا لو أعادت الأداة شيئًا آخر في الخطوة 1؟» تأخذ أحدث مدخل في السجل التاريخي للخطوة fromStep، وتطبّق patch، وتحفظ الناتج نقطةَ حفظ بالحالة 'in-progress' للجلسة الجديدة، وتعيد { sessionId, step, checkpoint }. استأنف التفريعة كأي تشغيل غير مكتمل:
مع createAgent({ store })، تفعل agent.fork(sessionId, { fromStep, patch? }) الشيء نفسه على store.checkpoints، وتواصل agent.resume(fork.sessionId) التفريعة:
  • يُطبَّق patch بهذا الترتيب: messages(messages) تعيد كتابة سجل المحادثة؛ وbusinessState يحلّ محلّ القيمة المحفوظة؛ وtoolResult: { toolCallId, result } يستبدل نتيجة ذلك الاستدعاء في مكانها (الموضع نفسه ومعرّف الاستدعاء نفسه واسم الأداة نفسه، فيبقى سجل المحادثة صالحًا لدى كل مزوّد)، أو يسجّلها حين لا تكون للاستدعاء نتيجة بعد؛ وappendInput يضع رسالة مستخدم في الانتظار، تُرسل بعد أي استدعاءات أدوات ما زالت معلّقة.
  • تحتفظ التفريعة بعدد خطوات نقطة الحفظ المصدر وباستهلاكها، فينطبق عليها ما تبقّى من ميزانية maxSteps. نتائج الأدوات المسجَّلة لا يُعاد تنفيذها؛ واستدعاءات الأدوات التي بلا نتيجة تُنفَّذ أولًا، كما في أي استئناف (فتفريعة من مدخل بالحالة 'awaiting-approval' تمرّ عبر needsApproval من جديد؛ ولا يُنقل approvalId القديم).
  • يسمّي newSessionId التفريعة؛ والافتراضي <sessionId>.fork-<n> حيث n أول عدد ليست له نقطة حفظ. ويُرفض newSessionId الذي له نقطة حفظ أصلًا.
  • الخطوة غير الموجودة في السجل التاريخي (الجلسة غير معروفة، أو الخطوة لم تُنفَّذ قط، أو أُسقطت لتجاوزها historyLimit) ترمي SDKError برمز الخطأ LOUSHO_CHECKPOINT_NOT_FOUND، ورسالته تسرد الخطوات المحفوظة. والمخزن الذي بلا history() لا يمكن التفريع منه (ConfigurationError).
  • تأخذ compareTrajectories(a, b) نقطتي حفظ أو سجلَّي محادثة وتعيد خطوات كل تشغيل (خطوة لكل دورة للمساعد: نصها واستدعاءات أدواتها مع نتائجها)، وdivergedAt (أول خطوة تختلف، بصرف النظر عن معرّفات استدعاءات الأدوات)، وdrift، وهو الفروق في ترتيب الأدوات والمعطيات وعدد الخطوات وسبب الانتهاء، بالبنية نفسها التي يخرجها lousho eval --drift.

الاستئناف بوكيل تغيَّر

كل نقطة حفظ وكل لقطة موافقة تحمل بصمة الوكيل (agent fingerprint): معرّف النموذج، واسم كل أداة مع تجزئة (hash) لمخطط JSON الخاص بمدخلها، وتجزئة لتعليمات النظام (إضافةً إلى SHA-256 قصير محسوب عليها كلها باستخدام Web Crypto). الدوال والقيم الأخرى التي لا يمكن تحويلها إلى بيانات متسلسلة (execute الخاصة بالأداة، والخطّافات، ودوال رد النداء) ليست جزءًا منها، فتغيير الشيفرة وحدها لا يُعدّ تغييرًا أبدًا. وتُخزَّن الأجزاء كلٌّ على حدة ليتمكن عدم التطابق من بيان ما الذي تغيّر. حين يُستأنف تشغيل (agent.resume(id)، أو session.resume()، أو استدعاء لاحق لـexecute() على sessionId غير مكتمل، أو agent.approvals.resolve()، والصيغ المبثوثة منها)، تُقارَن بصمة الوكيل المستأنِف بالبصمة المحفوظة، قبل أي استدعاء للنموذج أو تنفيذ لأداة. ويحدّد onAgentDrift في createAgent() (وفي AgentExecutor.execute() / resumeAfterApproval()) ما يترتب على الاختلاف: استدعاء الأداة المعلّق الذي لم تعد أداته موجودة (استدعتها دورة النموذج الأخيرة وليست لها نتيجة بعد، أو وافق عليها الإنسان) يُرفض دائمًا بالخطأ LOUSHO_RESUME_TOOL_MISSING، أيًّا كان الخيار.
ملاحظات:
  • نقاط الحفظ واللقطات المكتوبة قبل وجود هذه الميزة ليست لها بصمة، وتُستأنف تمامًا كما في السابق، بلا تحذير.
  • تشغيل وكيل ديناميكي يحفظ ctx والنموذج الذي اختاره في نقطة الحفظ (runConfig)، ويستخدمهما الاستئناف بعد تعطّل: النموذج المحفوظ، وأدوات وتعليمات تُحسب من جديد بالـctx المحفوظ (وكان الحساب قبل ذلك يجري بـinput: [] وبلا metadata).
  • تغطي البصمة أدوات الوكيل نفسه ونموذجه وتعليماته، لا الأدوات التي تضيفها subagents وskills. والوكيل الفرعي الذي يتوقف بانتظار موافقة له بصمته الخاصة في لقطته المتداخلة، لكن الاستئناف لا يقارن إلا وكيل المستوى الأعلى.
  • التشغيل المستأنف بعد موافقة يواصل بالتعليمات التي بدأ بها (فهي في سجل محادثته)، ولذلك تسجّل نقاط حفظه اللاحقة تلك التعليمات.

التوافق مع البيانات المخزَّنة

  • نقاط الحفظ المكتوبة قبل وجود status تُعامَل على أنها 'in-progress' (فقبل هذا التغيير كانت التشغيلات المنتهية تحذف نقطة حفظها، فلم يكن يُخزَّن إلا التشغيلات غير المكتملة).
  • الجلسات المحفوظة قبل أن يحتفظ المخزن بسجل تاريخي يبقى سجلها التاريخي فارغًا حتى الحفظ التالي.
  • لقطات الموافقة المكتوبة قبل وجود remainingToolCalls تُستأنف كما كانت تُستأنف: لا تُنفَّذ الاستدعاءات التي تلي الاستدعاء المتوقف. ويحصل كل منها على نتيجة خطأ تفيد بأنه لم يُنفَّذ، فيبقى سجل المحادثة صالحًا.

القيود

  • يعرّف sessionId محادثة منطقية واحدة؛ وتشغيل استدعاءين لـexecute() على المعرّف نفسه في الوقت نفسه غير مدعوم.
  • إذا استدعيت resumeAfterApproval() من دون checkpointStore، تحتفظ الجلسة بعلامة 'awaiting-approval' وتظل استدعاءات execute() اللاحقة ترمي SessionAwaitingApprovalError؛ مرّر المخزن (أو احذف نقطة الحفظ).
  • Workers KV متّسق اتساقًا نهائيًا (eventual consistency) عبر المواقع؛ انظر النشر لمعرفة تفاصيل هذا التحفّظ.