ساخت یک agent harness: حلقه و پنج راه خروج از آن
حلقهای پانزدهخطی که بار اول کار میکند، سپس عمداً هفت بار میشکند؛ از runawayای که 77 برابر نسخه محدود هزینه داشت.
در این صفحه
از بخش صادقانه شروع کنیم، چون هیچکس دیگری آن را نمیگوید: «harness» اصطلاح تخصصی است، نه یک استاندارد. نه specificationای وجود دارد، نه کمیتهای، نه تعریف مرجع. چهار مقالهای که این فصل به آنها ارجاع میدهد — ReAct،1 CoALA،2 SWE-bench و vLLM — در چکیدههایشان حتی یک بار هم از این واژه استفاده نمیکنند. پردانلودترین پیادهسازیِ این چیز، پکیج ai از Vercel با 89.4 میلیون دانلود در ماه، آن را به کار نمیبرد: رشته harness در 397 KB declarationهای type که نسخه 7.0.93 منتشر کرده، صفر بار ظاهر میشود.3 تنها جایی که این واژه واقعاً نقش سازهای دارد، معنایی کاملاً متفاوت میدهد. SWE-bench در README خود پنج بار میگوید «harness»، همیشه بهصورت evaluation harness — داربست containerشدهای که patch را اعمال میکند و testها را اجرا میکند — و ماژول Python آن واقعاً swebench.harness.run_evaluation است.4
پس دو چیز متفاوت یک نام مشترک دارند. یک evaluation harness، agent را ثابت نگه میدارد و امتیازش میدهد. یک agent harness برنامهای است که agent را اجرا میکند: مدل را فراخوانی میکند، آنچه مدل درخواست کرده اجرا میکند، تصمیم میگیرد چه زمانی متوقف شود، و state میان اینها را نگه میدارد. این فصل دومی را در کمتر از دویست خط TypeScript میسازد، بدون هیچ frameworkای.
خود حلقه پانزده خط است و در اولین تلاش کار میکند. هر چیزی بعد از آن، راهی برای خروج از آن است.
نمایش جزئیات
این فصل از فصلهای قبلی چه لازم دارد.
- فصل 14 برای client: deadlineها، triage وضعیت، cancellation، idempotency keyها، و تکنیک mock provider که اینجا هم دوباره استفاده میشود.
- فصل 16 برای حسابوکتاب: input tokenها با مربع مکالمه رشد میکنند، و نرخهای استفادهشده در ادامه همانهایی هستند که آنجا در 6 سپتامبر 2026 خوانده شدند.
- فصل 18 برای کاتالوگ ابزار: schemaای که مدل میبیند، endpointای که هرگز نمیبیند، و این قاعده که خطاها context هستند نه exception.
- فصل 22 برای حلقهای که این یکی از آن ارث میبرد، و برای دو تعریف منتشرشده از «agent» که با هم توافق ندارند.
اینجا خبری از tensor نیست. این دومین هاب وابستگی دوره است: فصلهای 24، 25، 29 و 30 روی فایل زیر اجرا میشوند، و 26 تا 28 بر اساس چیزهایی ساخته میشوند که این فایل میتواند به آنها برسد.
ارائهدهندهای که میتوانید script کنید
لینک به بخش: ارائهدهندهای که میتوانید script کنیدفصل 14 را نمیشد علیه یک ارائهدهنده واقعی نوشت، چون نمیتوانید در لحظهای که انتخاب کردهاید از آن یک 429 بخواهید. این فصل همان مشکل را در شکلی متفاوت دارد: نمیتوانید از یک مدل واقعی بخواهید runaway شود، یا دو بار پشت سر هم دقیقاً همان ابزار را درخواست کند، آن هم on demand و قابل بازتولید.
پس برنامه اول یک scripted provider است: endpointای با شکل یک chat completions API که پاسخ آن تابعی از index نوبت و چیزهایی است که ابزارها تا آن لحظه برگرداندهاند. tokenها را با یک byte-pair encoder واقعی میشمارد، بنابراین پولی که پایین میآید حساب است، نه تزئین.
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);دو خط طراحی را حمل میکنند. index نوبت از مکالمه مشتق میشود، نه اینکه در یک متغیر نگه داشته شود، پس ارائهدهنده stateless است و یک run را میتوان کشت و دوباره علیه آن ادامه داد. و recover قبل از تصمیم گرفتن نتایج ابزار را میخواند: یک مدل scriptشده که transcript خودش را میخواند، حداقل چیزی است که برای اندازهگیری اینکه آیا harness چیزی ارزش خواندن به آن داده یا نه لازم دارید.
کاتالوگ همان کاتالوگ فصل 18 است، چهار ابزار روی سه فایل: list_files، read_file، delete_file — علامتخورده با needsApproval — و scan_archive، که عمداً کند است.
حلقهای که کار میکند
لینک به بخش: حلقهای که کار میکندایده کامل همین است، قبل از هر بخشی که آن را قابل بقا کند.
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 provider وصل کنید و دقیقاً همان کاری را میکند که از ظاهرش پیداست:
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 است که جایی میرسد که هیچکس چیزی تایپ نکرده. باقی این فصل درباره اتفاقی است که میافتد وقتی آن خط از رشد کردن نمیایستد.
شکست اول: taskای که هرگز تمام نمیشود
لینک به بخش: شکست اول: taskای که هرگز تمام نمیشودهمان حلقه را به script runaway وصل کنید — مدلی که در هر نوبت یک ابزار میخواهد و هرگز نثر تولید نمیکند — و return علامتخورده هرگز اجرا نمیشود. خروجی دیگری وجود ندارد. برنامه اجرا میشود تا process بمیرد یا کارت اعتباری.
راهحل یک خط است، نخستین کنترلی است که ادبیات توصیه میکند،5 و همه بالاخره آن را مینویسند. کاری که تقریباً هیچکس نمیکند، اندازهگیری ارزش آن است:
| سقف نوبت | فراخوانیهای مدل | input tokenها | هزینه |
|---|---|---|---|
| 8 | 8 | 3,431 | $0.009070 |
| 20 | 20 | 16,259 | $0.038038 |
| 50 | 50 | 88,649 | $0.191098 |
| 100 | 100 | 337,299 | $0.702198 |
دو ردیف آخر را با هم بخوانید. دو برابر کردن سقف از 50 به 100 هزینه را دو برابر نکرد؛ آن را 3.7 برابر کرد. input tokenها از 88,649 به 337,299 رسیدند، یعنی ضریب 3.8، چون نوبت هر نوبت قبلی را با خود حمل میکند و مجموع است. سقف نوبت یک پیچ خطی نیست. پیچی روی ریشه دوم بدترین حالت شماست؛ برای همین بالا بردنش از 20 به 100 «برای اطمینان» تصمیمی است که پیش از گرفتنش باید قیمتگذاری شود.
شکست دوم: سقف نوبت سقف پول نیست
لینک به بخش: شکست دوم: سقف نوبت سقف پول نیستمشکل سقف نوبت این است که یک نوبت قیمت ثابت ندارد. بیست نوبت روی transcript کوتاه، بالا $0.038 هزینه داشت. بیست نوبت با یک کاتالوگ 200 ابزاری، مجموعهای از سندهای retrievalشده و چهل پیام history، صدها برابر آن هزینه دارد، و سقف از آن خبر ندارد. چیزی که operator میخواهد محدود کند، قبض است.
پس حلقه پول را میشمارد، با استفاده از computeCost فصل 16 و نرخهایی که آنجا خوانده شدند — $2.00 برای هر میلیون input token و $12.00 برای هر میلیون output، برای مدلی که در سراسر این دوره قیمتگذاری شده است:
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 runaway، بدون هیچ سقف نوبتی، سه بودجه:
| بودجه | نوبتهای رسیده | مقدار واقعی خرجشده |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $0.205398 |
دو چیز ارزش نامگذاری دارند. اول، بودجه هر بار تعداد متفاوتی نوبت میخرد، که دقیقاً نکته همین است: همان چیزی را محدود میکند که operator برایش اهمیت قائل است، و اجازه میدهد تعداد نوبتها جایی بیفتد که transcript آن را میگذارد. دوم، هر ردیف overshoot دارد. بودجه $0.010 بود و $0.010780 خرج شد، چون check قبل از یک نوبت اجرا میشود و قیمت یک نوبت تا وقتی تمام نشود معلوم نیست. نمیتوانید خرج را دقیقاً محدود کنید؛ میتوانید آن را تا فاصله هزینه یک نوبت محدود کنید. این را در interface بگویید نه اینکه وانمود کنید، و check را قبل از call بگذارید تا overshoot یک نوبت باشد نه دو نوبت.
پنج راه برای خروج از حلقه، نه یکی
لینک به بخش: پنج راه برای خروج از حلقه، نه یکیتا اینجا حلقه سه خروجی دارد، و شکل باقی فصل معلوم شده است. یک run تولیدی دقیقاً به یکی از پنج روش تمام میشود، و اینها variationهای یکدیگر نیستند:
| چگونه تمام میشود | چه کسی تصمیم گرفت | caller باید چه کند |
|---|---|---|
| مدل دیگر چیزی نخواست | مدل | پاسخ را بخواند |
| سقف نوبت | شما، از قبل | سقف را بالا ببرد، یا نتیجه جزئی را بپذیرد |
| بودجه تمام شد | شما، از قبل | پول بیشتری تأیید کند، یا نتیجه جزئی را بپذیرد |
| خطایی که نمیتوانید retry کنید | ارائهدهنده یا یک ابزار | deployment را درست کند؛ triage فصل 14 تصمیم میگیرد |
| انسان مداخله کرد | یک نفر | منتظر verdict بماند، سپس resume کند |
فشرده کردن اینها در یک boolean رایجترین اشتباه طراحی در این فایل است، و هزینهاش به شکلی مشخص بالا میرود: سه تا از پنج مورد resumable هستند و دو تا نیستند. agentای که به سقف نوبتش رسیده transcript معتبر، نتیجه جزئی واقعی و next step دارد؛ agentای که 401 گرفته هیچکدام را ندارد. بنابراین harness دلیل را بهصورت data ثبت میکند:
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 };شکست سوم: یک ابزار fail میشود
لینک به بخش: شکست سوم: یک ابزار fail میشودفصل 18 با ادعایی بدون عدد تمام شد: خطای یک ابزار را بهعنوان نتیجه ابزار به مدل برگردانید، نه اینکه آن را raise کنید، و مدل معمولاً خودش را اصلاح میکند. این عددش است.
یک failure، سه policy. مدل scriptشده نام فایلی را حدس میزند که وجود ندارد؛ ابزار no such file: timeout.log. Call list_files to see what exists. throw میکند
| harness با خطا چه میکند | نوبتها | اجرای ابزار | هزینه | کاربر چه گرفت |
|---|---|---|---|---|
| آن را از حلقه بیرون throw میکند | 1 | 1 | $0.000756 | یک stack trace |
Error: the tool failed. برمیگرداند | 2 | 1 | $0.001462 | «نتوانستم فایل را بخوانم، پس نمیدانم.» |
| چیزی را برمیگرداند که واقعاً اتفاق افتاد | 4 | 3 | $0.003550 | «errors.log به یک timeout اشاره میکند.» |
ردیف سوم 4.7 برابر ردیف اول هزینه دارد و تنها ردیفی است که به سؤال جواب میدهد. و ردیف دوم جالب است، چون همان کاری است که بیشتر codebaseها واقعاً انجام میدهند: خطا catch شد، حلقه زنده ماند، به مدل گفته شد که چیزی fail شده اما نه چه چیزی، و مدل مؤدبانه دست کشید. تفاوت ردیفهای دو و سه error handling نیست. جملهای است که برای یک خواننده نوشته شده.
پس harness با یک ابزار throwشده مثل data رفتار میکند، و wording را policy میسازد:
} 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 درباره سوی دیگر هم هشدار داده بود، و آن هم قیمت دارد. حلقه را به ابزاری وصل کنید که به دلیلی fail میشود که هیچ پیامی نمیتواند درستش کند — خواندنی که process اجازه انجامش را ندارد — و مدل آن را برای همیشه retry میکند:
read a file the process may not open turns=12 toolruns=11 in=7,079 cost=$0.018622
status=max_turns_exceeded answer=""یازده اجرای یکسان از callای که نمیتواند موفق شود، 5.2 برابر هزینه runای که از یک خطای قابل اصلاح recover کرد، و در پایان هیچ چیز. خطاها context هستند؛ خطای دائمی contextای است که باقی run را مسموم میکند. این تمایز همان triage وضعیت فصل 14 است که یک لایه بالاتر آمده: خطایی که مدل میتواند بر اساس آن عمل کند به transcript برمیگردد، و خطایی که نمیتواند باید run را با یک دلیل متوقف کند. امروز سقف نوبت چیزی است که بین شما و حالت دوم ایستاده، و این کف است نه fix.
شکست چهارم: همان call، دوبار
لینک به بخش: شکست چهارم: همان call، دوبارحالا failureای که بیشتر مردم فکر میکنند نمیتواند اتفاق بیفتد. مدلها خودشان را تکرار میکنند. هر حلقهای را آنقدر طولانی اجرا کنید و خواهید دید که ابزار یکسان با argumentهای یکسان در دو نوبت پیاپی میآید.
در مقایسه با baseline همان task بدون تکرار:
| نوبتها | اجرای ابزار | هزینه | |
|---|---|---|---|
| task، بدون تکرار | 2 | 1 | $0.001396 |
| همان task، یک call تکراری | 3 | 2 | $0.002446 |
| تکراری، با result cache روی ابزارهای read-only | 3 | 1 | $0.002446 |
call تکراری $0.001050 اضافه هزینه داشت، یعنی افزایش 75٪، و اینجاست که مردم غافلگیر میشوند: caching نتیجه هیچکدام از آن را برنگرداند. Deduplication اجرای ابزار را ذخیره کرد نه نوبت را، چون تا وقتی کد شما متوجه تکرار شود، پول مدل برای درخواستش پرداخت شده است. این صرفهجویی وقتی ابزار کند، rate-limited، یا per-call billed باشد واقعی است — و روی line itemای که رشد کرده صفر است.
نسخه بدتری هم هست. همان cache را روی ابزاری که مینویسد اعمال کنید، و call دوم بیصدا اتفاق نمیافتد:
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"]کدامیک درست است؟ هیچکدام، و دانستنی هم نیست. protocol میگوید اینها دو call هستند: دو مقدار متفاوت tool_call_id دارند. argumentها میگویند شاید یکی باشند. harnessای که با مقایسه رشته argumentها تصمیم میگیرد، یک روز دومین مورد از دو charge یکسان و عمدی را میبلعد — و فصل 14 قبلاً تنها مکانیزمی را نام برده که صادقانه این را حل میکند: idempotency keyای که برای هر عملیات منطقی، توسط لایهای تولید میشود که میداند آن عملیات چیست. تا وقتی ابزار یکی داشته باشد، پیشفرض قابل دفاع همان gate read-only بالاست: readها را cache کنید، writeها را اجرا کنید، و بگذارید idempotency خود write بقیه را مدیریت کند.
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 فایلها را فهرست میکند و بعد درخواست حذف فایلی را میدهد که task هرگز ذکر نکرده. هیچچیز در حلقه تا اینجا جلویش را نمیگیرد.
ابزاری که با needsApproval علامت خورده، نه fail میشود و نه ادامه میدهد. run را متوقف میکند و کنترل را برمیگرداند، با هر چیزی که یک انسان برای تصمیم گرفتن لازم دارد:
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) });
}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، بخش بعدی است: بین توقف و verdict، ممکن است process دیگر وجود نداشته باشد.
اما اول، اندازهگیریای که هیچکس انتظارش را ندارد. رد کردن، نبودنِ نتیجه نیست — transcript یک slot دارد که با tool_call_id کلید خورده و باید چیزی داخلش برود. همان رد را دوبار اجرا کنید، فقط چیزی را که آن slot میگوید تغییر دهید:
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."در هیچکدام از دو run چیزی حذف نشد، و در دومی به کاربر گفته میشود که حذف شده است. سیستم permission بینقص کار کرد؛ گزارش دروغ است. همان مکانیزم جدول خطای ابزار است، اما جایی ظاهر میشود که خیلی مهمتر است — یک انسان نه گفت، action درست block شد، و summary agent واقعیت را نقض میکند چون امتناع هرگز در جایی که مدل میخواند نوشته نشد. قاعدهای که از این بیرون میآید کوتاه است: هر تصمیمی که کد شما درباره یک tool call میگیرد، آن تصمیم را با کلمات در transcript بنویسید. فصل 30 از سمت امنیت به این برمیگردد، جایی که تفاوت میان audit trail و داستانپردازی است.
شکست ششم: process میمیرد
لینک به بخش: شکست ششم: process میمیردیک approval چند دقیقه یا چند ساعت طول میکشد. یک deploy چند ثانیه. اگر run در یک متغیر محلی داخل یک HTTP request زندگی کند، هر restart یک run ازدسترفته است و هر approval یک race.
پس run یک closure نیست. یک object ساده serialisable است — پیامها، تعداد نوبت، هزینه، status، interruption، فهرست call idهای تأییدشده — و حلقه یک pure function روی آن است. همین constraint واحد است که persistence را به دغدغهای یکخطی تبدیل میکند:
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"));سؤال correctness ذخیره کردن نیست. این است که در مسیر برگشت چه اتفاقی میافتد، و پاسخ naive به شما دوباره charge میزند. اگر process بعد از اینکه مدل ابزار خواست اما قبل از نوشته شدن نتیجه مرده باشد، resumeای که با دوباره فراخوانی مدل شروع شود، پول نوبتی را میدهد که قبلاً دارد — و اگر با دوباره اجرای ابزارها شروع شود، یک write را دوبار انجام میدهد.
راهحل این است که حلقه در ابتدا از transcript بپرسد چه چیزی outstanding است:
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));
}هر iteration اول pending را drain میکند و فقط وقتی هیچ outstandingای وجود ندارد از مدل میپرسد. Resume به همان code path معمول تبدیل میشود، و approval هم همینطور — یک call تأییدشده فقط pending callای است که حالا اجازه اجرا دارد. process را وسط task بکشید و دوباره راهاندازی کنید:
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دو اجرای ابزار در دو process برای taskای که به دو اجرا نیاز دارد، و هزینه نهایی با runای که هرگز crash نکرد یکسان است. هزینه از میان restart جمع میشود چون در state بود، نه در یک متغیر.
شکست هفتم: سه دقیقه سکوت
لینک به بخش: شکست هفتم: سه دقیقه سکوتscan_archive اینجا سه ثانیه طول میکشد و جای ابزاری مینشیند که در production سه دقیقه طول میکشد. وقتی اجرا میشود دو چیز کم است: کاربر هیچ ایدهای ندارد چیزی در حال انجام است، و دکمه Stop هیچ کاری نمیکند.
هر دو یک fix دارند، و آن AbortSignal فصل 14 است که یک سطح عمیقتر رفته. signal فقط برای fetch نیست — به داخل ابزار پاس داده میشود، و ابزار خوشنوشته به آن احترام میگذارد:
result = await tool.run(JSON.parse(c.function.arguments), {
signal,
progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});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"دو میلیثانیه از click تا stop، چون sleep داخل ابزار به همان signal گوش میدهد که fetch گوش میدهد. آن را فقط به fetch thread کنید و همان دکمه Stop سه ثانیه منتظر میماند — طول ابزار — و run بعد از اینکه کاری که قرار بود cancel شود تمام شده، «cancel» میشود. Cancellationای که تا پایینترین لایه plumbing نشده، spinnerای است که کلمه درست را میگوید.
trace، و چرا log نیست
لینک به بخش: trace، و چرا log نیستharness برای هر event یک خط emit میکند، و vocabulary آن آنقدر کوچک است که میشود حفظش کرد: turn، tool_start، tool_progress، tool_result، approval_required، run_stopped.
{"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 دارد، پس runای که سه process و دو روز را طی میکند یک query است. هر خط turn token countهای خودش و هزینه جاری را دارد، پس «چرا این run چهل دلار هزینه داشت» بعد از واقعه قابل پاسخ است، نه اینکه فقط در theory قابل بازتولید باشد. و run_stopped دلیل را حمل میکند، یعنی fieldای که یک ticket پشتیبانی را به پاسخ یکخطی تبدیل میکند: agentای که به budget رسید و agentای که crash کرد از بیرون یکسان به نظر میرسند و پاسخهای مخالف لازم دارند.
حساب latency
لینک به بخش: حساب latencyفصل 13 time to first token را روی سختافزاری که مالک آن هستید اندازه گرفت. فصل 14 آن را از طریق socket اندازه گرفت. یک agent آن را ضرب میکند، و multiplier عددی است که هیچکس انتخاب نکرده:
همان task سهنوبتی، با تغییر فقط latency ارائهدهنده:
| latency ارائهدهنده در هر نوبت | wall clock، 3 نوبت |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2,413 ms |
خود harness به یک run سهنوبتی پانزده میلیثانیه اضافه میکند. هر چیز دیگر است ضربدر عددی که کنترلش نمیکنید — داخل serving schedulerای تنظیم شده که request شما را با requestهای غریبهها batch میکند6 — و را مدل انتخاب میکند. برای همین streaming فصل 14 اینجا مهمتر از chat است و کمتر کمک میکند: میتوانید نوبت آخر را stream کنید، و چهار نوبت قبل از آن سکوتاند مگر اینکه harness progress emit کند. این کل استدلال برای event tool_progress بالاست — در یک agent، واحد صادقانه feedback، token نیست؛ step است.
همان harness، با یک مدل واقعی پشت port
لینک به بخش: همان harness، با یک مدل واقعی پشت portهمه چیز بالا علیه scripted provider اجرا شد، که harness را ثابت میکند و درباره مدلها هیچ چیز ثابت نمیکند. پس یک خط را عوض کنید — seam فصل 14، LLM_BASE_URL — و همان کد را به یک Qwen2.5-0.5B-Instruct محلی با همان چهار ابزار وصل کنید. شش task روی همان سه فایل:
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سه finding، و سومی دلیل وجود این بخش است.
تکتک taskها دقیقاً در دو نوبت تمام شدند. سقف نوبت هرگز اجرا نشد، budget هرگز اجرا نشد، و تنها خروجی حلقه تولید نثر توسط مدل بود. یک مدل نیممیلیاردپارامتری iterate نمیکند؛ در نفس دومش جواب میدهد، چه چیزی را که لازم دارد داشته باشد چه نداشته باشد. تعداد نوبت property مدل است، نه حلقه شما.
میانگین هر نوبت 6,908 میلیثانیه بود، پس جدول latency بالا اسباببازی نیست: در این اندازه، یک run فرضی هشتنوبتی تقریباً یک دقیقه wall clock است با هیچ چیز روی صفحه.
و جوابها غلطاند. بزرگترین فایل errors.log است؛ مدل فایلها را فهرست کرد، هرگز آنها را نخواند، و بااینحال یکی را نام برد. task اول نام فایلی را حدس زد، به او گفته شد وجود ندارد، و نتیجهگیری کرد. harness در هر شش run بینقص اجرا شد. harness یک agent را قابل حاکمیت میکند، نه درست — فصل 29 راه فهمیدن اینکه کدام است را نشان میدهد، و فصل 30 نشان میدهد وقتی هیچکس این را نفهمیده باشد چه هزینهای دارد.
subagentها، اینجا نامگذاری میشوند و بعداً charge میشوند
لینک به بخش: subagentها، اینجا نامگذاری میشوند و بعداً charge میشوندیک ابزار در کاتالوگ میتواند پشت خودش run دیگری داشته باشد. interface همان interface فصل 18 است — یک schema و یک endpoint — و یک agent کامل میتواند پشت آن جا بگیرد چون آن interface باریک است:
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";
},
};سه چیز همین حالا در آن ده خط درست است و هر سه پیامد تصمیمهایی هستند که بالا گرفته شدند: child window خودش را دارد، پس transcript والد بهجای هر چیزی که child خوانده، یک summary میگیرد؛ limitهای خودش را دارد، پس child runaway نمیتواند budget والد را خرج کند؛ و signal را به ارث میبرد، پس یک Stop کل tree را cancel میکند. اینکه چرا window تمیز نکته اصلی است نه side effect، موضوع فصل 24 است؛ پنج pattern orchestration — prompt chaining، routing، parallelisation، orchestrator-workers، evaluator-optimiser — و handoff در فصل 25 هستند.
frameworkها کجا هستند، و چرا این دوره از هیچکدام استفاده نکرد
لینک به بخش: frameworkها کجا هستند، و چرا این دوره از هیچکدام استفاده نکردهیچچیز بالا نباید بهعنوان استدلالی علیه libraryها خوانده شود. اندازهگیریشده در 7 سپتامبر 2026، برای ماه منتهی به 29 اوت:7
| package | دانلودهای آن ماه | چه چیزی به شما میدهد |
|---|---|---|
ai (Vercel AI SDK) | 89,385,860 | ToolLoopAgent، stopWhen، approval ابزار، step hookها |
@anthropic-ai/claude-agent-sdk | 41,558,352 | Claude Code harness بهعنوان library: حلقه، sessionها، hookها، permissionها، subagentها8 |
@langchain/langgraph | 12,812,815 | حلقه بهعنوان state graph صریح |
langchain | 11,359,058 | chainها، agentها، integrationها |
@openai/agents | 6,093,155 | agentها، handoffها، guardrailها |
@mastra/core | 5,914,502 | agentها، workflowها، memory |
دلیل اینکه این دوره حلقه را دستی مینویسد بهجای اینکه یکی از آنها را آموزش دهد، اعلام میشود نه تلویحی، و قابل اندازهگیری است. در دوازده ماه تا 7 سپتامبر 2026، ai 945 نسخه منتشر کرد و از major 5 به major 7 رفت، و class agent آن هنوز بهصورت Experimental_Agent export میشود؛ langchain در همان بازه 132 نسخه منتشر کرد؛ @openai/agents 83 نسخه منتشر کرد و پانزده ماه پس از اولین release هنوز روی 0.x است.7 فصلی که علیه هرکدام از آن APIها نوشته شود ظرف یک فصل stale میشود، و این فصل به سیوسه زبان منتشر میشود، پس هر re-edition هزینه کل translation را دارد. چیزی که زیر همه آنهاست حرکت نمیکند: یک حلقه، یک stopping rule، یک کاتالوگ، یک executor، مقداری state.
و reference implementation در بخش مهم با این فصل موافق است. در ai نسخه 7.0.93، خروجی حلقه یک عدد نیست — stopWhen است، فهرستی از predicateها، که step count فقط یکی از آنهاست:3
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>; // exported as stepCountIsStopping در پرکاربردترین پیادهسازی این حلقه جمع است، به همان دلیلی که در صد و نود و شش خط بالا جمع است.
بعد از این به کجا میرویم
لینک به بخش: بعد از این به کجا میرویمحالا یک harness دارید: یک حلقه، یک کاتالوگ، یک executor، پنج راه خروج، یک run persisted، signalای که به ابزارها میرسد، و traceای که روی هر خط run id دارد. فصلهای 24، 25، 29 و 30 روی این فایل ساخته میشوند، و 26 تا 28 روی چیزهایی که این فایل میتواند به آنها برسد.
یک مشکل باقی مانده، و اندازهگیریهای بالا تمام مدت به آن اشاره کردهاند. دوباره به جدول runaway نگاه کنید: 3,431 input token در هشت نوبت، 337,299 در صد نوبت. به run کارآمد نگاه کنید: 204، 269، 342. هر نوبت کل transcript را دوباره میفرستد، پس context یک agent با history خودش پر میشود — و مدل در استفاده از انتهای دور یک window بلند بدتر از انتهای نزدیک آن است، برای همین agent خوب در نوبت پنج در نوبت چهل سردرگم میشود.
سقف نوبت این را fix نمیکند. فقط جلوی شما را میگیرد که پول بدهید و تماشایش کنید. چیزی که fix میکند، تصمیم گرفتن در تکتک نوبتهاست که کدام tokenها شایسته window هستند: چه چیزی compact شود، چه چیزی به noteای منتقل شود که agent بتواند fetch کند، چه چیزی به subagent با window تمیز سپرده شود، و کدام تعریفهای ابزار ارزش مالیات دائمیشان را دارند. فصل 24 اندازه میگیرد window واقعاً کجا میرود — و نکته غافلگیرکننده این است که آنجا مکالمه نیست.
منابع و روش
لینک به بخش: منابع و روشهر عددی در این فصل از دو server توصیفشده بالا بیرون آمد، روی Node 22 از طریق loopback interface: scripted providerای که tokenها را با encoding o200k_base میشمرد، و Qwen/Qwen2.5-0.5B-Instruct پشت endpointای با همان شکل، greedy decoding، روی CPU. هزینهها از token countهای اندازهگیریشده با نرخهایی محاسبه شدهاند که فصل 16 در 6 سپتامبر 2026 خواند — $2.00 برای هر میلیون input token و $12.00 برای هر میلیون output — و هیچ requestی در این فصل به endpoint پولی نرفت. جوابهای مدل محلی، جوابهای یک مدل کوچکاند؛ آنها را بهعنوان evidence درباره حلقه بخوانید، که در هر دو حالت یکسان است، نه بهعنوان benchmarkی از کاری که مدلهای امروزی انجام میدهند.
ارجاعات
لینک به بخش: ارجاعات-
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). درهمتنیدن reasoning traceها و actionهایی که حلقه پیادهسازی میکند، و منبع این مشاهده که acting به مدل اجازه میدهد «exceptionها را handle کند» — دقیقاً همان چیزی که جدول خطای ابزار بالا اندازه میگیرد. ↩
-
Sumers, T. R., Yao, S., Narasimhan, K. and Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). treatment formal کاری که حلقه بالا informal انجام میدهد: componentهای memory ماژولار، action space ساختاریافته که memory داخلی و محیطهای خارجی را در بر میگیرد، و «یک process generalized decision-making برای انتخاب actionها». آن را برای vocabularyای بخوانید که اصطلاح صنعتی کم دارد — مخصوصاً جداسازی working، episodic، semantic و procedural memory، که سایه عملیاش جدول سه-store فصل 24 است. ↩
-
ai(Vercel AI SDK) نسخه 7.0.93، منتشرشده در 4 سپتامبر 2026؛ declarationهای type ازcdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.tsدر 7 سپتامبر 2026 خوانده شدند. فایل 397 KB شامل صفر occurrence از رشتهharnessاست. class agent برابرdeclare class ToolLoopAgentاست، هم بهصورتToolLoopAgentو هم بهصورتExperimental_Agentexport شده؛declare function isStepCount(stepCount: number)— exportشده بهصورتstepCountIs— بالا verbatim نقل شده؛type StopConditionبدون type parameter دومش (RUNTIME_CONTEXT extends Context = Context) نشان داده شده، که تنها elision در excerpt است، همانطور که شکلstopWhen?: Arrayable<StopCondition<...>>رویgenerateTextوstreamTextنیز چنین است. همان فایلtoolApproval،ToolApprovalStatus،prepareStepوrepairToolCallرا declare میکند، یعنی reference implementation مستقلانه به approval gateها، آمادهسازی per-step و error repair رسیده است. ↩ ↩2 -
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). چکیده، artefact را یک «evaluation framework» شامل 2,294 مسئله مینامد و هرگز از واژه «harness» استفاده نمیکند؛ README خود پروژه (
github.com/SWE-bench/SWE-bench، خواندهشده در 7 سپتامبر 2026) پنج بار از آن استفاده میکند، همیشه بهصورت «evaluation harness»، و entry point برابرpython -m swebench.harness.run_evaluationاست. این معنای دیگر واژه است: scaffoldای که agent را ثابت نگه میدارد و امتیازش میدهد، نه حلقهای که آن را اجرا میکند. ↩ -
Anthropic, Building effective agents, 19 دسامبر 2024،
anthropic.com/engineering/building-effective-agents، خواندهشده در 7 سپتامبر 2026. augmented model بهعنوان building block، agent بهعنوان LLMای که «با استفاده از ابزارها بر اساس feedback محیطی در یک حلقه» کار میکند، و توصیه stopping conditionهایی «مانند حداکثر تعداد iterationها» برای حفظ کنترل. فصل 22 تعریف آن را کامل نقل میکند. ↩ -
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ای که request شما را با requestهای غریبهها batch میکند و KV cache فصل 13 را مدیریت میکند. دقیقاً چون مال شما نیست، دانستن اینکه وجود دارد ارزش دارد: latencyای که harness شما ضرب میکند داخل آن تعیین میشود، و هیچ مقدار کار روی حلقه شما آن را جابهجا نمیکند. ↩
-
شمار دانلودهای npm registry،
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>، یک window صریح بهجای window rollinglast-month، و release historyها ازregistry.npmjs.org/<package>؛ هر دو در 7 سپتامبر 2026 query شدند. release countها تعداد versionهای منتشرشده در دوازده ماه تا آن تاریخ هستند، شامل canary buildها:ai945 (آخرین 7.0.93 در 2026-09-04، با major versionهای 5، 6 و 7 که همگی داخل window ظاهر شدهاند)،langchain132 (آخرین 1.5.10 در 2026-08-20)،@openai/agents83 (آخرین 0.17.0 در 2026-08-19، اولین انتشار 2025-06-03). ↩ ↩2 -
Claude Agent SDK (
@anthropic-ai/claude-agent-sdk) همان Claude Code harness است که بهصورت library بستهبندی شده — حلقه agent، ابزارهای built-in فایل و shell، context management، sessionها، hookها، permissionها و subagentها — و درcode.claude.com/docs/en/agent-sdkمستند شده. این نزدیکترین چیز به یک روایت منتشرشده از هر مکانیزمی است که این فصل دستی میسازد، و خواندنش کنار implementation خودتان برای بخشهایی که نام میبرد و این فصل فقط به آنها اشاره میکند ارزش دارد. ↩