تخطَّ إلى المحتوى
23/30الفصل 23 من 30

ابنِ Agent Harness: الحلقة وخمس طرق للخروج منها

حلقة من 15 سطرًا تعمل من أول محاولة، ثم نكسرها عمدًا سبع مرات، بدءًا بانفلات كلّف 77 ضعف نسخة محكومة بإحكام.

في هذه الصفحة

ابدأ بالجزء الصريح، لأن أحدًا غيرنا لن يقوله: "harness" مصطلح دارج، وليس معيارًا. لا توجد مواصفة، ولا لجنة، ولا تعريف مرجعي. الأوراق الأربع التي يستشهد بها هذا الفصل — ReAct،1 وCoALA،2 وSWE-bench وvLLM — لا تستخدم الكلمة ولو مرة واحدة في ملخصاتها. أكثر تنفيذ مُنزّل لهذا الشيء، حزمة Vercel ‏ai بعدد 89.4 مليون تنزيل شهريًا، لا يستخدمها أيضًا: السلسلة harness تظهر صفر مرة في 397 KB من تعريفات الأنواع التي يشحنها الإصدار 7.0.93.3 الموضع الوحيد الذي تكون فيه الكلمة حاسمة يعني شيئًا آخر تمامًا. يقول SWE-bench كلمة "harness" خمس مرات في README الخاص به، دائمًا بمعنى evaluation harness — الهيكل الحاوي الذي يطبق patch ويشغّل الاختبارات — ووحدة Python الخاصة به هي حرفيًا swebench.harness.run_evaluation.4

لذلك يشترك شيئان مختلفان في اسم واحد. Evaluation harness يثبّت agent في مكانه ويقيّمه. أما agent harness فهو البرنامج الذي يشغّل agent: يستدعي النموذج، وينفّذ ما يطلبه النموذج، ويقرر متى يتوقف، ويحمل الحالة بين ذلك. يبني هذا الفصل النوع الثاني، في أقل من مئتي سطر من TypeScript، من دون أي framework.

الحلقة نفسها خمسة عشر سطرًا وتعمل من أول محاولة. كل ما يأتي بعد ذلك هو طريقة للخروج منها.

عرض التفاصيل

ما يحتاجه هذا الفصل من الفصول السابقة.

  • الفصل 14 للعميل: المهل النهائية، وفرز الحالات، والإلغاء، ومفاتيح idempotency، وتقنية المزوّد الوهمي المستخدمة هنا مرة أخرى.
  • الفصل 16 للحساب: input tokens تنمو مع مربع المحادثة، والأسعار المستخدمة أدناه هي الأسعار المقروءة هناك في 6 سبتمبر 2026.
  • الفصل 18 لكتالوج الأدوات: schema يراه النموذج، وendpoint لا يراه أبدًا، والقاعدة التي تقول إن الأخطاء context لا استثناءات.
  • الفصل 22 للحلقة التي يرثها هذا الفصل، وللتعريفين المنشورين لكلمة "agent" اللذين يختلفان معًا.

لا tensors هنا. هذا هو مركز الاعتماد الثاني في الدورة: الفصول 24 و25 و29 و30 تعمل على الملف أدناه، والفصول 26 إلى 28 تبني على ما يستطيع الوصول إليه.

لم يكن ممكنًا كتابة الفصل 14 ضد مزوّد حقيقي، لأنك لا تستطيع أن تطلب منه 429 في لحظة تختارها. لدى هذا الفصل المشكلة نفسها بشكل مختلف: لا يمكنك أن تطلب من نموذج حقيقي أن ينفلت، أو أن يطلب الأداة نفسها بالحجج نفسها مرتين متتاليتين، عند الطلب وبقابلية إعادة الإنتاج.

لذلك يكون البرنامج الأول مزوّدًا scripted: endpoint له شكل chat completions API تكون إجابته دالة في فهرس الدور وما أعادته الأدوات حتى الآن. وهو يعدّ tokens باستخدام byte-pair encoder حقيقي، لذا فالمال أدناه حساب لا زينة.

mock-provider.mjsJS
const SCRIPTS = {
  // A well-behaved task: list, read, answer.
  plan: (t) =>
    t === 0 ? asks(call("c1", "list_files", {}))
    : t === 1 ? asks(call("c2", "read_file", { path: "errors.log" }))
    : text("errors.log mentions a timeout: worker 7 timed out after 30000 ms."),

  // Never declares itself done.
  runaway: (t) => asks(call(`c${t}`, "list_files", {})),           

  // Guesses a file name, then corrects itself IF it was told what happened.
  recover: (t, all) =>
    t === 0 ? asks(call("c1", "read_file", { path: "timeout.log" }))
    : /Call list_files/.test(all)                                  
      ? (t === 1 ? asks(call("c2", "list_files", {}))
        : t === 2 ? asks(call("c3", "read_file", { path: "errors.log" }))
        : text("errors.log mentions a timeout."))
      : text("I could not read the file, so I do not know."),
};

const turn = messages.filter((m) => m.role === "assistant").length;             
const toolText = messages.filter((m) => m.role === "tool").map((m) => m.content).join("\n");
const message = SCRIPTS[scenario](turn, toolText);

سطران يحملان التصميم. فهرس الدور مشتق من المحادثة، لا محفوظ في متغير، لذلك يكون المزوّد عديم الحالة ويمكن قتل تشغيل واستئنافه ضده. وrecover يقرأ نتائج الأدوات قبل أن يقرر: نموذج scripted يقرأ transcript الخاص به هو الحد الأدنى اللازم لقياس ما إذا كان harness قد أعطاه شيئًا يستحق القراءة.

الكتالوج هو كتالوج الفصل 18، أربع أدوات على ثلاثة ملفات: list_files، وread_file، وdelete_file — معلّمة needsApproval — وscan_archive، وهي بطيئة عن قصد.

هذه هي الفكرة كلها، قبل أي من الأجزاء التي تجعلها قابلة للنجاة.

loop.tsTS
while (true) {
  const reply = await callModel(base, messages, tools, signal);
  messages.push(reply.message);

  const calls = reply.message.tool_calls ?? [];
  if (!calls.length) return reply.message.content;          

  for (const c of calls) {
    const tool = byName.get(c.function.name);
    const result = await tool.run(JSON.parse(c.function.arguments));
    messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: result });
  }
}

وجّهها إلى المزوّد scripted وستفعل بالضبط ما يبدو أنها تفعله:

TEXT
plan, cap 20    turns=3  tools=2  in=815  out=70  cost=$0.002470  ms=89  status=completed
   answer: "errors.log mentions a timeout: worker 7 timed out after 30000 ms."
   per-turn prompt tokens: 204, 269, 342

ثلاثة أدوار، تنفيذان للأدوات، وربع سنت أمريكي. لاحظ السطر الأخير: 204، 269، 342. كل دور يعيد إرسال كل ما سبقه، وهذه فاتورة الفصل 16 التربيعية تصل إلى مكان لم يكتب فيه أحد شيئًا. بقية هذا الفصل هي ما يحدث عندما لا يتوقف ذلك السطر عن النمو.

الكسر الأول: المهمة التي لا تنتهي أبدًا

رابط إلى القسم: الكسر الأول: المهمة التي لا تنتهي أبدًا

وجّه الحلقة نفسها إلى script ‏runaway — نموذج يطلب أداة في كل دور بلا استثناء ولا يصدر نثرًا أبدًا — ولن يُفعّل return المعلّم. لا يوجد مخرج آخر. يستمر البرنامج حتى تموت العملية أو تموت بطاقة الائتمان.

الإصلاح سطر واحد، وهو أول تحكم توصي به الأدبيات،5 والجميع يكتبه في النهاية. ما لا يفعله تقريبًا أحد هو قياس قيمته:

حد الأدواراستدعاءات النموذجinput tokensالتكلفة
883,431$0.009070
202016,259$0.038038
505088,649$0.191098
100100337,299$0.702198

اقرأ الصفين الأخيرين معًا. مضاعفة الحد من 50 إلى 100 لم تضاعف التكلفة؛ بل ضربتها في 3.7. انتقلت input tokens من 88,649 إلى 337,299، أي عامل 3.8، لأن الدور nn يحمل كل دور سابق معه والمجموع هو Θ(n2)\Theta(n^2). حد الأدوار ليس قرصًا خطيًا. إنه قرص على الجذر التربيعي لأسوأ حالة لديك، ولهذا فإن رفعه من 20 إلى 100 «فقط للاحتياط» قرار يستحق تسعيره قبل اتخاذه.

الكسر الثاني: حد الأدوار ليس حدًا للمال

رابط إلى القسم: الكسر الثاني: حد الأدوار ليس حدًا للمال

مشكلة حد الأدوار أن الدور ليس له سعر ثابت. عشرون دورًا فوق transcript قصير كلفت $0.038 أعلاه. عشرون دورًا مع كتالوج من 200 أداة، ومجموعة مستندات مسترجعة، وأربعين رسالة من التاريخ تكلف مئات الأضعاف، والحد لا يعرف. ما يريد المشغّل تقييده هو الفاتورة.

لذلك تحسب الحلقة المال، باستخدام computeCost من الفصل 16 مقابل الأسعار المقروءة هناك — $2.00 لكل مليون input tokens و$12.00 لكل مليون output، للنموذج المسعّر في هذه الدورة كلها:

harness.tsTS
const PRICE_IN = 2.0 / 1e6, PRICE_OUT = 12.0 / 1e6;
export const cost = (u: Usage) => u.prompt_tokens * PRICE_IN + u.completion_tokens * PRICE_OUT;

// at the top of every iteration, before asking the model anything:
if (state.turns >= opts.limits.maxTurns) return stop("max_turns_exceeded", { type: "max_turns" });
if (state.costUsd >= opts.limits.maxBudgetUsd) return stop("budget_exceeded", { type: "max_budget" }); 

// ...and once the reply is back, before anything else happens with it:
state.costUsd += cost(reply.usage);

script الانفلات نفسه، بلا حد أدوار على الإطلاق، وثلاث ميزانيات:

الميزانيةالأدوار التي تم بلوغهاما أُنفق فعليًا
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

هناك شيئان يستحقان التسمية. أولًا، تشتري الميزانية عددًا مختلفًا من الأدوار في كل مرة، وهذا هو المقصود: إنها تحدّ الشيء الذي يهتم به المشغّل، وتترك عدد الأدوار يقع حيث يضعه transcript. ثانيًا، كل صف يتجاوز الحد. كانت الميزانية $0.010 وأُنفق $0.010780، لأن الفحص يجري قبل الدور ولا يُعرف سعر الدور إلا بعد انتهائه. لا يمكنك تقييد الإنفاق بدقة؛ يمكنك تقييده ضمن تكلفة دور واحد. قل ذلك في الواجهة بدل التظاهر، وضع الفحص قبل الاستدعاء كي يكون التجاوز دورًا واحدًا لا دورين.

خمس طرق للخروج من الحلقة، لا واحدة

رابط إلى القسم: خمس طرق للخروج من الحلقة، لا واحدة

بات لدى الحلقة الآن ثلاثة مخارج، وصار شكل بقية الفصل مرئيًا. ينتهي تشغيل production بطريقة واحدة بالضبط من خمس طرق، وليست هذه تنويعات على الشيء نفسه:

كيف ينتهيمن قررماذا ينبغي للمنادي أن يفعل
توقف النموذج عن الطلبالنموذجاقرأ الإجابة
حد الأدوارأنت، مسبقًاارفع الحد، أو اقبل نتيجة جزئية
استُنفدت الميزانيةأنت، مسبقًاوافق على مزيد من المال، أو اقبل نتيجة جزئية
خطأ لا يمكنك إعادة محاولتهالمزوّد أو أداةأصلح النشر؛ فرز الفصل 14 هو من يقرر
تدخل إنسانشخصانتظر الحكم، ثم استأنف

طيّ هذه كلها في boolean واحد هو أكثر خطأ تصميمي شيوعًا في هذا الملف، وهو مكلف بطريقة محددة: ثلاثة من الخمسة قابلة للاستئناف واثنان ليستا كذلك. agent وصل إلى حد أدواره لديه transcript صالح، ونتيجة جزئية حقيقية، وخطوة تالية؛ أما agent حصل على 401 فلا يملك شيئًا من ذلك. لذلك يسجل harness السبب كبيانات:

harness.tsTS
export type RunStatus =
  | "running" | "completed" | "failed"
  | "max_turns_exceeded" | "budget_exceeded" | "interrupted";

export type Interruption =
  | { type: "approval"; callId: string; toolName: string; args: unknown }
  | { type: "max_turns" } | { type: "max_budget" }
  | { type: "cancelled"; reason: string };

أنهى الفصل 18 بادعاء بلا رقم: أعد خطأ الأداة إلى النموذج كنتيجة أداة بدل رفعه، وعادةً ما يصلح النموذج نفسه. ها هو الرقم.

فشل واحد، وثلاث سياسات. النموذج scripted يخمّن ملفًا غير موجود؛ الأداة ترمي no such file: timeout.log. Call list_files to see what exists.

ما يفعله harness بالخطأالأدوارتشغيلات الأدواتالتكلفةما حصل عليه المستخدم
يرميه خارج الحلقة11$0.000756stack trace
يعيد Error: the tool failed.21$0.001462"لم أستطع قراءة الملف، لذلك لا أعرف."
يعيد ما حدث فعليًا43$0.003550"يذكر errors.log مهلة انتهت."

يكلف الصف الثالث 4.7 أضعاف الأول، وهو الوحيد الذي يجيب عن السؤال. والصف الثاني هو المثير للاهتمام، لأنه ما تفعله معظم قواعد الكود فعليًا: التُقط الخطأ، ونجت الحلقة، وقيل للنموذج أن شيئًا فشل لا ماذا فشل، فاستسلم بأدب. الفرق بين الصفين الثاني والثالث ليس معالجة أخطاء. إنه جملة مكتوبة لقارئ.

لذلك يعامل harness الأداة التي ترمي كبيانات، ويجعل الصياغة سياسة:

harness.tsTS
} catch (err: any) {
  if (signal.aborted) return stop("interrupted", { type: "cancelled", reason: String(signal.reason) });
  if (opts.toolErrorsAreFatal) { state.error = err.message; return stop("failed"); }
  result = (opts.toolErrorText ?? ((e: Error) => `Error: ${e.message}`))(err);   
}

حذّر الفصل 18 أيضًا من الجانب الآخر، وله هو أيضًا ثمن. وجّه الحلقة إلى أداة تفشل لسبب لا تستطيع أي رسالة إصلاحه — قراءة لا يُسمح للعملية بأدائها — وسيعيد النموذج المحاولة إلى الأبد:

TEXT
read a file the process may not open   turns=12  toolruns=11  in=7,079  cost=$0.018622
                                      status=max_turns_exceeded   answer=""

أحد عشر تنفيذًا متطابقًا لاستدعاء لا يمكن أن ينجح، بتكلفة 5.2 أضعاف التشغيل الذي تعافى من خطأ قابل للإصلاح، ولا شيء في النهاية. الأخطاء context؛ والخطأ الدائم context يسمّم بقية التشغيل. التمييز هو فرز حالات الفصل 14 منقولًا طبقة إلى أعلى: الخطأ الذي يستطيع النموذج التصرف بناءً عليه يعود إلى transcript، والخطأ الذي لا يستطيع يجب أن يوقف التشغيل مع سبب. حد الأدوار هو ما يقف بينك وبين الحالة الثانية اليوم، وهذا أرضية لا إصلاح.

الكسر الرابع: الاستدعاء نفسه، مرتين

رابط إلى القسم: الكسر الرابع: الاستدعاء نفسه، مرتين

الآن الفشل الذي يفترض معظم الناس أنه لا يمكن أن يحدث. النماذج تكرر نفسها. اطلب من أي حلقة أن تعمل مدة كافية وسترى الأداة نفسها بالحجج نفسها في دورين متتاليين.

مقيسًا مقابل baseline للمهمة نفسها دون التكرار:

الأدوارتشغيلات الأدواتالتكلفة
المهمة، بلا تكرار21$0.001396
المهمة نفسها، استدعاء واحد مكرر32$0.002446
مكرر، مع cache نتائج على أدوات القراءة فقط31$0.002446

كلّف الاستدعاء المكرر $0.001050 إضافية، أي زيادة 75 %، وهنا الجزء الذي يفاجئ الناس: لم تسترد cache النتيجة أيًا من ذلك. وفرت deduplication تنفيذ الأداة لا الدور، لأنه بحلول الوقت الذي يلاحظ فيه كودك التكرار يكون النموذج قد تقاضى أجر طلبه بالفعل. التوفير حقيقي عندما تكون الأداة بطيئة، أو محدودة المعدل، أو مفوترة حسب الاستدعاء — وهو صفر في بند التكلفة الذي نما.

هناك نسخة أسوأ. طبّق cache نفسها على أداة تكتب، ولن يحدث الاستدعاء الثاني بصمت:

TEXT
naive cache on every tool        3 turns, 1 tool run,  files deleted: ["access.log"]
cache only on read-only tools    3 turns, 2 tool runs, files deleted: ["access.log","access.log"]

أيّهما صحيح؟ لا هذا ولا ذاك، على نحو يمكن معرفته. يقول البروتوكول إن هذه استدعاءان: يحملان قيمتي tool_call_id مختلفتين. تقول الحجج إنهما قد يكونان واحدًا. harness يقرر بمقارنة سلاسل الحجج سيبتلع يومًا ما الثاني من رسومين متطابقين مقصودين — وقد سمّى الفصل 14 الآلية الوحيدة التي تحل هذا بأمانة، وهي مفتاح idempotency يولّده، لكل عملية منطقية، المستوى الذي يعرف ما هي العملية. إلى أن تحمل الأداة واحدًا، يكون الافتراض الدفاعي هو بوابة القراءة فقط أعلاه: cache للقراءات، تنفيذ للكتابات، ودع idempotency الخاصة بالكتابة تتولى الباقي.

harness.tsTS
if (opts.dedupe && (tool.readOnly || opts.dedupeAll) && seen.has(signature)) {   
  state.messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: seen.get(signature)! });
  continue;
}

script ‏destructive يسرد الملفات ثم يطلب حذف ملف لم تذكره المهمة قط. لا شيء في الحلقة حتى الآن سيمنعه.

الأداة المعلّمة needsApproval لا تفشل ولا تتابع. إنها توقف التشغيل وتعيد التحكم، ومعه كل ما يحتاجه شخص ليقرر:

harness.tsTS
if (tool.needsApproval && !state.approved.includes(c.id)) {
  trace(state.runId, "approval_required", { toolName: tool.name, args: c.function.arguments, callId: c.id });
  return stop("interrupted", { type: "approval", callId: c.id, toolName: tool.name, args: JSON.parse(c.function.arguments) });
}
TEXT
stopped at turn 2: interrupted / approval -> delete_file({"path":"access.log"})
files deleted so far: []
approve -> total turns=3  deleted=["access.log"]  "Deleted access.log to free space."
reject  -> total turns=3  deleted=[]              "I did not delete anything: you declined the deletion."

هذه هي الآلية كلها، والسبب في أنها return لا callback هو القسم التالي: بين التوقف والحكم، قد لا تكون العملية موجودة أصلًا.

لكن أولًا، القياس الذي لا يتوقعه أحد. الرفض ليس غياب نتيجة — في transcript خانة keyed by ‏tool_call_id ويجب أن يدخل فيها شيء. شغّل الرفض نفسه مرتين، مع تغيير ما يقوله ذلك الشيء فقط:

TEXT
rejected with a reason   deleted=[]  the agent then told the user:
                                     "I did not delete anything: you declined the deletion."
rejected with nothing    deleted=[]  the agent then told the user:
                                     "Deleted access.log to free space."

لم يُحذف شيء في أي من التشغيلين، وفي الثاني يُقال للمستخدم إنه حُذف. عمل نظام الأذونات عملًا مثاليًا؛ التقرير كذبة. إنها الآلية نفسها في جدول أخطاء الأدوات، لكنها تصل إلى مكان أهم بكثير — قال إنسان لا، وحُظر الإجراء بشكل صحيح، وملخص agent يناقض الواقع لأن الرفض لم يُكتب قط حيث يقرأ النموذج. القاعدة الناتجة عن ذلك قصيرة: مهما كان قرار كودك بشأن استدعاء أداة، اكتب القرار في transcript بكلمات. يعود الفصل 30 إلى هذا من جانب الأمان، حيث يكون الفرق بين سجل تدقيق وخيال.

تستغرق الموافقة دقائق أو ساعات. يستغرق deploy ثواني. إذا كان التشغيل يعيش في متغير محلي داخل طلب HTTP، فكل إعادة تشغيل هي تشغيل ضائع، وكل موافقة سباق.

لذلك فالتشغيل ليس closure. إنه كائن عادي قابل للتسلسل — رسائل، وعدد أدوار، وتكلفة، وحالة، وانقطاع، وقائمة call ids الموافق عليها — والحلقة دالة صرفة فوقه. هذا القيد الوحيد هو ما يجعل الاستمرارية مسألة سطر واحد:

harness.tsTS
export const save = (s: RunState, dir: string) => writeFileSync(`${dir}/${s.runId}.json`, JSON.stringify(s));
export const load = (dir: string, runId: string) => JSON.parse(readFileSync(`${dir}/${runId}.json`, "utf8"));

سؤال الصحة ليس الحفظ. بل ما يحدث في طريق العودة، والإجابة الساذجة تفرض عليك تكلفة مزدوجة. إذا ماتت العملية بعد أن طلب النموذج أداة وقبل أن تُكتب النتيجة، فإن استئنافًا يبدأ باستدعاء النموذج مرة أخرى يدفع مقابل دور يملكه بالفعل — وإذا بدأ بإعادة تشغيل الأدوات، فإنه ينفذ كتابة مرتين.

الإصلاح هو أن تبدأ الحلقة بسؤال transcript عما هو معلّق:

harness.tsTS
export function pending(state: RunState): ToolCall[] {
  const answered = new Set(state.messages.filter((m) => m.role === "tool").map((m) => m.tool_call_id));
  const last = state.messages.at(-1);
  if (last?.role !== "assistant") return [];
  return (last.tool_calls ?? []).filter((c) => !answered.has(c.id));    
}

كل تكرار يفرّغ pending أولًا ولا يسأل النموذج إلا عندما لا يبقى شيء معلّق. يصبح الاستئناف مسار الكود نفسه كالمسار العادي، وكذلك الموافقة — الاستدعاء الموافق عليه هو ببساطة استدعاء معلّق صار مسموحًا بتشغيله. اقتل العملية في منتصف المهمة وأعد تشغيلها:

TEXT
process died after turn 2. tool runs so far: list_files, read_file:errors.log
restored from disk: turns=2  cost=$0.001570  messages=6  status=running
resumed and finished: turns=3  cost=$0.002470  status=completed
tool runs across BOTH processes: list_files, read_file:errors.log

تنفيذان للأدوات عبر عمليتين لمهمة تحتاج اثنين، والتكلفة النهائية مطابقة للتشغيل الذي لم يتعطل قط. تتراكم التكلفة عبر إعادة التشغيل لأنها كانت في الحالة، لا في متغير.

الكسر السابع: ثلاث دقائق من الصمت

رابط إلى القسم: الكسر السابع: ثلاث دقائق من الصمت

scan_archive يستغرق ثلاث ثوانٍ هنا ويمثل الأداة التي تستغرق ثلاث دقائق في production. هناك شيئان مفقودان أثناء تشغيله: لا يعرف المستخدم أن شيئًا يحدث، وزر Stop لا يفعل شيئًا.

كلاهما له الإصلاح نفسه، وهو AbortSignal من الفصل 14 مدفوعًا مستوى أعمق. الإشارة ليست للـ fetch فقط — بل تُمرّر إلى الأداة، والأداة المكتوبة جيدًا تحترمها:

harness.tsTS
result = await tool.run(JSON.parse(c.function.arguments), {
  signal,                                                                     
  progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});
TEXT
progress: scanned 200 of 1200 files  (t+506 ms)
progress: scanned 400 of 1200 files  (t+1007 ms)
no cancellation:            stopped after 3,015 ms, status=completed
user presses Stop at 1.2 s: stopped after 1,202 ms, status=interrupted, reason="user pressed Stop"

ميليثانيتان من النقرة إلى التوقف، لأن sleep داخل الأداة يستمع إلى الإشارة نفسها التي يستمع إليها fetch. مرّرها فقط إلى fetch وسيظل زر Stop نفسه ينتظر ثلاث ثوانٍ — طول الأداة — و«يُلغي» التشغيل بعد أن يكون العمل الذي كان يلغيه قد انتهى بالفعل. الإلغاء الذي لا يُوصّل إلى الأسفل كله spinner يقول الكلمة الصحيحة.

يبث harness سطرًا واحدًا لكل حدث، والمفردات صغيرة بما يكفي لحفظها: turn، tool_start، tool_progress، tool_result، approval_required، run_stopped.

TEXT
{"runId":"n1","type":"turn","turn":1,"prompt_tokens":204,"completion_tokens":23,"total_tokens":227,"costUsd":0.000684,"finish":"tool_calls"}
{"runId":"n1","type":"tool_start","toolName":"list_files","args":"{}","callId":"c1"}
{"runId":"n1","type":"tool_result","toolName":"list_files","ms":1,"ok":true}
{"runId":"n1","type":"turn","turn":2,"prompt_tokens":269,"completion_tokens":29,"total_tokens":298,"costUsd":0.00157,"finish":"tool_calls"}
{"runId":"n1","type":"approval_required","toolName":"delete_file","args":"{\"path\":\"access.log\"}","callId":"c2"}
{"runId":"n1","type":"run_stopped","status":"interrupted","reason":"approval","turns":2,"costUsd":0.00157}

ثلاث خصائص تجعل هذا trace لا logging. يحمل كل سطر run id، لذلك يكون التشغيل الممتد عبر ثلاث عمليات ويومين استعلامًا واحدًا. يحمل كل سطر turn أعداد token الخاصة به والتكلفة الجارية، لذلك يصبح سؤال «لماذا كلف هذا التشغيل أربعين دولارًا» قابلًا للإجابة بعد الواقعة بدل أن يكون قابلًا للإعادة نظريًا فقط. ويحمل run_stopped السبب، وهو الحقل الذي يحوّل تذكرة دعم إلى إجابة من سطر واحد: agent توقف عند الميزانية وagent تعطل يبدوان متطابقين من الخارج ويحتاجان ردين متعاكسين.

قاس الفصل 13 time to first token على عتاد تملكه. وقاسه الفصل 14 عبر socket. يضاعفه agent، والمضاعف رقم لم يختره أحد:

TrunN(tmodel+ttools)T_{\text{run}} \approx N \cdot \left( t_{\text{model}} + t_{\text{tools}} \right)

المهمة ذات الأدوار الثلاثة نفسها، مع تغيير latency المزوّد فقط:

latency المزوّد لكل دورwall clock، 3 أدوار
0 ms15 ms
200 ms615 ms
800 ms2,413 ms

يساهم harness نفسه بخمس عشرة ميليثانية في تشغيل من ثلاثة أدوار. كل ما عدا ذلك هو NN مضروبًا في رقم لا تتحكم فيه — مُعيّن داخل serving scheduler يجمّع طلبك مع طلبات غرباء6 — وNN يختاره النموذج. لهذا يهم streaming الفصل 14 هنا أكثر مما يهم في chat ويساعد أقل: يمكنك streaming الدور الأخير، والأدوار الأربعة قبله صمت إلا إذا بث harness تقدمًا. وهذا أيضًا هو الحجة كلها لحدث tool_progress أعلاه — في agent، وحدة التغذية الراجعة الصادقة ليست token، بل الخطوة.

harness نفسه، مع نموذج حقيقي خلف المنفذ

رابط إلى القسم: harness نفسه، مع نموذج حقيقي خلف المنفذ

كل ما سبق عمل ضد مزوّد scripted، وهذا يثبت harness ولا يثبت شيئًا عن النماذج. لذلك غيّر سطرًا واحدًا — seam من الفصل 14، LLM_BASE_URL — ووجّه الكود المطابق إلى Qwen2.5-0.5B-Instruct محلي مع الأدوات الأربع نفسها. ست مهام على الملفات الثلاثة نفسها:

TEXT
turns=2 tools=1 wall= 15,260ms  Which file mentions a timeout?      -> "The file timeout.txt does not exist..."
turns=2 tools=1 wall= 13,037ms  How many files are in the directory? -> "There are three files..."
turns=2 tools=1 wall= 10,121ms  Read notes.txt and tell me what it says. -> "Remember to rotate your logs."
turns=2 tools=2 wall= 21,290ms  List the files and then read each one.
turns=2 tools=1 wall= 10,698ms  Which file is the largest?          -> "The largest file is access.log."
turns=2 tools=1 wall= 12,490ms  Is there a file about rotating logs?
TOTAL turns=12  toolruns=7  wall=82,896ms  mean turn=6,908ms

ثلاث نتائج، والثالثة هي سبب وجود هذا القسم.

انتهت كل مهمة بلا استثناء في دورين بالضبط. لم يُفعّل حد الأدوار، ولم تُفعّل الميزانية، وكان المخرج الوحيد للحلقة هو أن ينتج النموذج نثرًا. نموذج بنصف مليار parameter لا يكرر؛ يجيب في نفسه الثاني سواء كان يملك ما يحتاجه أم لا. عدد الأدوار خاصية للنموذج، لا لحلقتك.

استغرق الدور الوسطي 6,908 ميليثانية، لذا فجدول latency أعلاه ليس لعبة: عند هذا الحجم، تشغيل افتراضي من ثمانية أدوار يقترب من دقيقة wall clock بلا شيء على الشاشة.

والإجابات خاطئة. أكبر ملف هو errors.log؛ سرد النموذج الملفات، ولم يقرأها قط، وسمّى واحدًا على أي حال. خمنت المهمة الأولى اسم ملف، وقيل لها إنه غير موجود، ثم خلصت. نفّذ harness بلا عيب في كل التشغيلات الستة. يجعل harness الـ agent قابلًا للحكم، لا صحيحًا — الفصل 29 هو كيف تعرف أيهما، والفصل 30 هو ما يكلفه الأمر عندما لا يفعل أحد.

يمكن لأداة واحدة في الكتالوج أن يكون خلفها تشغيل آخر. الواجهة هي واجهة الفصل 18 — schema وendpoint — ويناسب agent كامل خلفها لأن تلك الواجهة ضيقة:

subagent.tsTS
const research: Tool = {
  name: "research",
  description: "Investigate one question and return a short summary.",
  parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"] },
  readOnly: true,
  async run(args, ctx) {
    const child = newRun(RESEARCH_SYSTEM, args.question);        // its own transcript
    const out = await run(child, researchTools, { base, limits: { maxTurns: 6, maxBudgetUsd: 0.05 }, signal: ctx.signal });
    return out.output ?? "no result";
  },
};

ثلاثة أشياء صحيحة بالفعل في تلك الأسطر العشرة، والثلاثة كلها عواقب لقرارات اتُّخذت أعلاه: لدى الطفل نافذته الخاصة، لذلك يتلقى transcript الأب ملخصًا لا كل ما قرأه الطفل؛ ولديه حدوده الخاصة، لذلك لا يستطيع طفل منفلت إنفاق ميزانية الأب؛ ويرث الإشارة، لذلك تلغي نقرة Stop واحدة الشجرة. لماذا تكون النافذة النظيفة هي المقصود لا أثرًا جانبيًا هو الفصل 24؛ وأنماط orchestration الخمسة — prompt chaining، وrouting، وparallelisation، وorchestrator-workers، وevaluator-optimiser — وhandoff هي الفصل 25.

أين توجد frameworks، ولماذا لم تستخدم هذه الدورة واحدًا

رابط إلى القسم: أين توجد frameworks، ولماذا لم تستخدم هذه الدورة واحدًا

لا ينبغي قراءة أي شيء أعلاه كحجة ضد المكتبات. مقيسًا في 7 سبتمبر 2026، للشهر المنتهي في 29 أغسطس:7

الحزمةالتنزيلات في ذلك الشهرما تمنحك إياه
ai (Vercel AI SDK)89,385,860ToolLoopAgent، stopWhen، موافقة الأدوات، step hooks
@anthropic-ai/claude-agent-sdk41,558,352Claude Code harness كمكتبة: الحلقة، الجلسات، hooks، الأذونات، subagents8
@langchain/langgraph12,812,815الحلقة كـ state graph صريح
langchain11,359,058chains، وagents، وintegrations
@openai/agents6,093,155agents، وhandoffs، وguardrails
@mastra/core5,914,502agents، وworkflows، وmemory

سبب كتابة هذه الدورة الحلقة يدويًا بدل تعليم واحدة منها مُعلن لا ضمني، وهو قابل للقياس. في الاثني عشر شهرًا حتى 7 سبتمبر 2026، نشر ai 945 إصدارًا وانتقل من major 5 إلى major 7، وما زالت فئة agent الخاصة به مُصدّرة باسم Experimental_Agent؛ نشر langchain 132 إصدارًا في النافذة نفسها؛ ونشر @openai/agents 83 إصدارًا وما زال على 0.x، بعد خمسة عشر شهرًا من أول إصدار له.7 فصل مكتوب مقابل أي من تلك APIs يصبح قديمًا خلال موسم، وهذا الفصل منشور بثلاث وثلاثين لغة، لذا فكل إعادة تحرير تكلف الترجمة كلها. ما تحتها جميعًا لا يتحرك: حلقة، وقاعدة توقف، وكتالوج، ومنفذ، وبعض الحالة.

ويتفق التنفيذ المرجعي مع هذا الفصل في الجزء المهم. في ai الإصدار 7.0.93 مخرج الحلقة ليس رقمًا — إنه stopWhen، قائمة predicates، لا يكون عدّ الخطوات إلا واحدًا منها:3

ai-sdk.tsTS
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>;   // exported as stepCountIs

التوقف جمع في أكثر تنفيذ مستخدم لهذه الحلقة، للسبب نفسه الذي يجعله جمعًا في الأسطر المئة والستة والتسعين أعلاه.

لديك الآن harness: حلقة، وكتالوج، ومنفذ، وخمس طرق للخروج، وتشغيل مستمر، وإشارة تصل إلى الأدوات، وtrace يحمل run id على كل سطر. تبني الفصول 24 و25 و29 و30 على هذا الملف، والفصول 26 إلى 28 على ما يستطيع الوصول إليه.

تبقى له مشكلة واحدة، وكانت القياسات أعلاه تشير إليها طوال الطريق. انظر إلى جدول الانفلات مرة أخرى: 3,431 input tokens عند ثمانية أدوار، و337,299 عند مئة. وانظر إلى التشغيل العامل: 204، 269، 342. كل دور يعيد إرسال transcript كله، لذلك يمتلئ context الخاص بـ agent بتاريخه هو — والنموذج أسوأ في استخدام الطرف البعيد من window طويلة منه في استخدام الطرف القريب، ولهذا يكون agent جيدًا عند الدور الخامس ومرتبكًا عند الدور الأربعين.

حد الأدوار لا يصلح ذلك. إنه يوقفك فقط عن الدفع لمشاهدة حدوثه. ما يصلحه هو أن تقرر، في كل دور بلا استثناء، أي tokens تستحق window: ما الذي تضغطه، وما الذي تنقله إلى ملاحظة يستطيع agent جلبها، وما الذي تسلّمه إلى subagent بنافذة نظيفة، وأي تعريفات أدوات تستحق ضريبتها الدائمة. يقيس الفصل 24 أين تذهب window فعليًا — والمفاجأة أنها ليست المحادثة.


خرج كل رقم في هذا الفصل من الخادمين الموصوفين أعلاه، على Node 22 عبر loopback interface: مزوّد scripted يعدّ tokens بترميز o200k_base، وQwen/Qwen2.5-0.5B-Instruct خلف endpoint بالشكل نفسه، greedy decoding، على CPU. تُحسب التكاليف من أعداد token المقاسة بالأسعار التي قرأها الفصل 16 في 6 سبتمبر 2026 — $2.00 لكل مليون input tokens و$12.00 لكل مليون output — ولم يذهب أي طلب في هذا الفصل إلى endpoint مدفوع. إجابات النموذج المحلي هي إجابات نموذج صغير؛ اقرأها كدليل على الحلقة، وهي متطابقة في الحالتين، لا كـ benchmark لما تفعله النماذج الحالية.

  1. Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. and Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). تشابك آثار الاستدلال والأفعال الذي تنفذه الحلقة، ومصدر الملاحظة بأن الفعل يسمح للنموذج بأن "handle exceptions" — وهو بالضبط ما يقيسه جدول أخطاء الأدوات أعلاه.

  2. Sumers, T. R., Yao, S., Narasimhan, K. and Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). المعالجة الرسمية لما تفعله الحلقة أعلاه بصورة غير رسمية: مكونات ذاكرة معيارية، وفضاء أفعال منظم يمتد عبر الذاكرة الداخلية والبيئات الخارجية، و"عملية اتخاذ قرار معممة لاختيار الأفعال". اقرأها للمفردات التي يفتقر إليها مصطلح الصناعة — خصوصًا فصل الذاكرة العاملة، والذاكرة الحدثية، والدلالية، والإجرائية، التي ظلها العملي هو جدول المخازن الثلاثة في الفصل 24.

  3. ai (Vercel AI SDK) الإصدار 7.0.93، نُشر في 4 سبتمبر 2026؛ قُرئت تعريفات الأنواع من cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts في 7 سبتمبر 2026. يحتوي ملف 397 KB على صفر ظهور للسلسلة harness. فئة agent هي declare class ToolLoopAgent، مُصدّرة باسم ToolLoopAgent وباسم Experimental_Agent؛ وdeclare function isStepCount(stepCount: number) — المُصدّر باسم stepCountIs — مقتبس حرفيًا أعلاه؛ ويُعرض type StopCondition من دون type parameter الثاني الخاص به (RUNTIME_CONTEXT extends Context = Context)، وهو الحذف الوحيد في المقتطف، كما هو شكل stopWhen?: Arrayable<StopCondition<...>> على generateText وstreamText. يعلن الملف نفسه toolApproval وToolApprovalStatus وprepareStep وrepairToolCall، أي إن التنفيذ المرجعي وصل مستقلًا إلى بوابات الموافقة، والتحضير لكل خطوة، وإصلاح الأخطاء. 2

  4. Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. and Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). يسمي الملخص الأثر "evaluation framework" من 2,294 مشكلة ولا يستخدم كلمة "harness" أبدًا؛ أما README الخاص بالمشروع (github.com/SWE-bench/SWE-bench، قُرئ في 7 سبتمبر 2026) فيستخدمها خمس مرات، دائمًا بمعنى "evaluation harness"، ونقطة الدخول هي python -m swebench.harness.run_evaluation. هذا هو المعنى الآخر للكلمة: هيكل يثبّت agent في مكانه ويقيّمه، لا الحلقة التي تشغّله.

  5. Anthropic، ‏Building effective agents، ‏19 ديسمبر 2024، anthropic.com/engineering/building-effective-agents، قُرئ في 7 سبتمبر 2026. النموذج المعزز كلبنة بناء، وagent باعتباره LLM "using tools based on environmental feedback in a loop"، والتوصية بشروط توقف "such as a maximum number of iterations" للحفاظ على التحكم. يقتبس الفصل 22 تعريفه كاملًا.

  6. Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. and Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). الحلقة الأخرى — serving scheduler الذي يجمّع طلبك مع طلبات غرباء ويدير KV cache في الفصل 13. يستحق معرفة وجوده تحديدًا لأنه ليس ملكك: latency التي يضاعفها harness تُعيّن داخله، ولا يحركها أي مقدار من العمل على حلقتك.

  7. أعداد تنزيلات npm registry، ‏api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>، وهي نافذة صريحة لا نافذة last-month المتحركة، وتواريخ الإصدارات من registry.npmjs.org/<package>؛ استُعلم عنهما في 7 سبتمبر 2026. أعداد الإصدارات هي عدد النسخ المنشورة في الاثني عشر شهرًا حتى ذلك التاريخ، بما في ذلك canary builds: ai 945 (الأحدث 7.0.93 في 2026-09-04، مع ظهور major versions 5 و6 و7 كلها داخل النافذة)، وlangchain 132 (الأحدث 1.5.10 في 2026-08-20)، و@openai/agents 83 (الأحدث 0.17.0 في 2026-08-19، أول نشر في 2025-06-03). 2

  8. Claude Agent SDK ‏(@anthropic-ai/claude-agent-sdk) هو Claude Code harness مُغلّف كمكتبة — agent loop، وأدوات ملفات وshell مدمجة، وإدارة context، وجلسات، وhooks، وأذونات، وsubagents — موثق في code.claude.com/docs/en/agent-sdk. إنه أقرب شيء إلى سرد منشور لكل آلية يبنيها هذا الفصل يدويًا، ويستحق القراءة بجانب تنفيذك الخاص للأجزاء التي يسميها بينما يلمّح إليها هذا الفصل فقط.

هل أنت مستعد لتترك الاختيار لـ LIA؟

ابنِ بكل نماذج الذكاء الاصطناعي في مكان واحد — ابدأ مجانًا اليوم.