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() بما يلي:
- تسجّل نتيجة الاستدعاء المتوقف: نتيجة الأداة إن وُوفق عليه، أو نتيجة رفض منظَّمة
{ error, note }إن رُفض؛ - تنفّذ الاستدعاءات المتبقية بمنطق الدفعة نفسه: فقد تُنفَّذ، أو تفشل في التحقق، أو توقف التشغيل مرة أخرى عند موافقة أخرى (
approvalIdجديد؛ احسمه بالطريقة نفسها، وبعدد المرات اللازم)؛ - تستدعي النموذج حالما يصبح لكل استدعاء في الدورة نتيجة واحدة بالضبط.
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) عبر المواقع؛ انظر النشر لمعرفة تفاصيل هذا التحفّظ.