בנו agent harness: הלולאה וחמש הדרכים לצאת ממנה
לולאה של 15 שורות שעובדת בניסיון הראשון, ואז נשברת בכוונה 7 פעמים — החל מריצה פרועה שעלתה פי 77 מריצה עם תקרה הדוקה.
בעמוד הזה
נתחיל בחלק הישר, כי אף אחד אחר לא יאמר אותו: ״harness״ הוא ז׳רגון, לא תקן. אין מפרט, אין ועדה, אין הגדרת ייחוס. ארבעת המאמרים שהפרק הזה מצטט — ReAct,1 CoALA,2 SWE-bench ו-vLLM — אינם משתמשים במילה אפילו פעם אחת בתקצירים שלהם. המימוש המורד ביותר של הדבר הזה, חבילת ai של Vercel עם 89.4 מיליון הורדות בחודש, אינו משתמש בה גם הוא: המחרוזת harness מופיעה אפס פעמים ב-397 KB של הצהרות הטיפוסים שנשלחו בגרסה 7.0.93.3 המקום היחיד שבו המילה כן נושאת משקל אומר משהו אחר לגמרי. SWE-bench אומר ״harness״ חמש פעמים ב-README שלו, תמיד בתור evaluation harness — שלד containerised שמחיל patch ומריץ את הבדיקות — ומודול ה-Python שלו הוא ממש swebench.harness.run_evaluation.4
אז שני דברים שונים חולקים שם. evaluation harness מחזיק את ה-agent במקום ומדרג אותו. agent harness הוא התוכנית שמריצה את ה-agent: היא קוראת למודל, מבצעת את מה שהמודל מבקש, מחליטה מתי לעצור, ומחזיקה את המצב ביניהם. הפרק הזה בונה את השני, בפחות ממאתיים שורות TypeScript, בלי framework בכלל.
הלולאה עצמה היא חמש-עשרה שורות והיא עובדת בניסיון הראשון. כל מה שאחריה הוא דרך לצאת ממנה.
הצגת פרטים
מה הפרק הזה צריך מהקודמים.
- פרק 14 בשביל הלקוח: deadlines, מיון סטטוסים, ביטול, idempotency keys, וטכניקת ה-mock provider שמשמשת כאן שוב.
- פרק 16 בשביל החשבון: input tokens גדלים עם ריבוע השיחה, והתעריפים שבהמשך הם אלה שנקראו שם ב-6 בספטמבר 2026.
- פרק 18 בשביל קטלוג הכלים: schema שהמודל רואה, endpoint שהוא לעולם לא רואה, והכלל ששגיאות הן context ולא exceptions.
- פרק 22 בשביל הלולאה שהפרק הזה יורש, ובשביל שתי ההגדרות שפורסמו ל-״agent״ וסותרות זו את זו.
אין כאן tensors. זהו מרכז התלויות השני של הקורס: פרקים 24, 25, 29 ו-30 רצים על הקובץ שלמטה, ו-26 עד 28 נבנים על מה שהוא יכול להגיע אליו.
ספק שאפשר לתסריט
קישור למקטע: ספק שאפשר לתסריטאי אפשר היה לכתוב את פרק 14 מול ספק אמיתי, כי אי אפשר לבקש ממנו 429 ברגע שבוחרים. לפרק הזה יש אותה בעיה בצורה אחרת: אי אפשר לבקש ממודל אמיתי לברוח משליטה, או לבקש את אותו כלי פעמיים ברצף, לפי דרישה ובאופן שחוזר על עצמו.
לכן התוכנית הראשונה היא ספק מתוסרט: endpoint בצורת chat completions API שהתשובה שלו היא פונקציה של אינדקס התור ושל מה שהכלים החזירו עד כה. הוא סופר tokens עם 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);שתי שורות נושאות את התכנון. אינדקס התור נגזר מהשיחה, לא מוחזק במשתנה, כך שהספק חסר מצב ואפשר להרוג ריצה ולחדש אותה מולו. ו-recover קורא את תוצאות הכלים לפני שהוא מחליט: מודל מתוסרט שקורא את התמליל של עצמו הוא המינימום שצריך כדי למדוד אם ה-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 });
}
}כוונו אותו אל הספק המתוסרט והוא עושה בדיוק מה שנראה שהוא עושה:
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 מגיע למקום שבו אף אחד לא הקליד כלום. שאר הפרק הזה הוא מה שקורה כשהשורה הזו לא מפסיקה לגדול.
שבירה ראשונה: המשימה שלא נגמרת
קישור למקטע: שבירה ראשונה: המשימה שלא נגמרתכוונו את אותה לולאה אל סקריפט runaway — מודל שמבקש כלי בכל תור ותור ולעולם אינו פולט פרוזה — וה-return המסומן לעולם לא מופעל. אין יציאה אחרת. התוכנית רצה עד שהתהליך מת או שכרטיס האשראי מת.
התיקון הוא שורה אחת, הוא הבקרה הראשונה שהספרות ממליצה עליה,5 וכולם כותבים אותה בסוף. מה שכמעט אף אחד לא עושה הוא למדוד מה היא שווה:
| תקרת תורים | קריאות מודל | input tokens | עלות |
|---|---|---|---|
| 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 tokens עלו מ-88,649 ל-337,299, פקטור של 3.8, כי תור נושא איתו את כל התורים הקודמים והסכום הוא . תקרת תורים אינה חוגה ליניארית. היא חוגה על השורש הריבועי של המקרה הגרוע ביותר שלכם, ולכן להעלות אותה מ-20 ל-100 ״רק ליתר ביטחון״ זו החלטה שכדאי לתמחר לפני שמקבלים אותה.
שבירה שנייה: תקרה על תורים אינה תקרה על כסף
קישור למקטע: שבירה שנייה: תקרה על תורים אינה תקרה על כסףהבעיה עם תקרת תורים היא שלתור אין מחיר קבוע. עשרים תורים מעל תמליל קצר עלו $0.038 למעלה. עשרים תורים עם קטלוג של 200 כלים, סט מסמכים שאוחזר וארבעים הודעות היסטוריה עולים פי מאות מזה, והתקרה לא יודעת. מה שהמפעיל רוצה לתחום הוא החשבון.
אז הלולאה סופרת כסף, באמצעות computeCost של פרק 16 מול התעריפים שנקראו שם — $2.00 למיליון input tokens ו-$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);אותו סקריפט בורח משליטה, בלי תקרת תורים בכלל, שלושה תקציבים:
| תקציב | תורים שהושגו | הוצאה בפועל |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $0.205398 |
שני דברים ראויים לשם. ראשית, התקציב קונה מספר אחר של תורים בכל פעם, וזאת הנקודה: הוא תוחם את הדבר שלמפעיל אכפת ממנו, ונותן לספירת התורים ליפול במקום שבו התמליל שם אותה. שנית, כל שורה חורגת. התקציב היה $0.010 והוצאו $0.010780, כי הבדיקה רצה לפני תור והמחיר של תור אינו ידוע עד שהוא נגמר. אי אפשר לתחום הוצאה בדיוק; אפשר לתחום אותה עד עלות של תור אחד. אמרו זאת בממשק במקום להעמיד פנים, ושימו את הבדיקה לפני הקריאה כדי שהחריגה תהיה תור אחד ולא שניים.
חמש דרכים לצאת מהלולאה, לא אחת
קישור למקטע: חמש דרכים לצאת מהלולאה, לא אחתבשלב הזה ללולאה יש שלוש יציאות, וצורת שאר הפרק כבר נראית. ריצת פרודקשן מסתיימת בדיוק באחת מחמש דרכים, והן אינן וריאציות זו של זו:
| איך זה נגמר | מי החליט | מה הקורא צריך לעשות |
|---|---|---|
| המודל הפסיק לבקש | המודל | לקרוא את התשובה |
| תקרת תורים | אתם, מראש | להעלות את התקרה, או לקבל תוצאה חלקית |
| התקציב אזל | אתם, מראש | לאשר עוד כסף, או לקבל תוצאה חלקית |
| שגיאה שאי אפשר לנסות מחדש | הספק או כלי | לתקן את הפריסה; המיון של פרק 14 מחליט |
| אדם התערב | בן אדם | לחכות לפסק דין, ואז להמשיך |
כיווץ כל אלה ל-boolean אחד הוא טעות התכנון הנפוצה ביותר בקובץ הזה, והיא יקרה באופן מסוים: שלוש מתוך החמש הן ניתנות לחידוש ושתיים לא. agent שפגע בתקרת התורים שלו מחזיק תמליל תקף, תוצאה חלקית אמיתית וצעד הבא; agent שקיבל 401 לא מחזיק אף אחד מאלה. לכן ה-harness מתעד את הסיבה כנתונים:
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 הסתיים בטענה בלי מספר: החזירו את שגיאת הכלי למודל כתוצאת כלי במקום להרים אותה, והמודל בדרך כלל מתקן את עצמו. הנה המספר.
כשל אחד, שלוש מדינויות. המודל המתוסרט מנחש קובץ שלא קיים; הכלי זורק no such file: timeout.log. Call list_files to see what exists.
| מה ה-harness עושה עם השגיאה | תורים | הרצות כלי | עלות | מה המשתמש קיבל |
|---|---|---|---|---|
| זורק אותה מחוץ ללולאה | 1 | 1 | $0.000756 | stack trace |
מחזיר Error: the tool failed. | 2 | 1 | $0.001462 | ״לא הצלחתי לקרוא את הקובץ, אז איני יודע.״ |
| מחזיר מה באמת קרה | 4 | 3 | $0.003550 | ״errors.log מזכיר timeout.״ |
השורה השלישית עולה פי 4.7 מהראשונה והיא היחידה שעונה על השאלה. והשורה השנייה היא המעניינת, כי זה מה שרוב בסיסי הקוד עושים בפועל: השגיאה נתפסה, הלולאה שרדה, למודל נאמר שמשהו נכשל ולא מה, והוא ויתר בנימוס. ההבדל בין שורות שתיים ושלוש אינו טיפול בשגיאות. הוא משפט שנכתב עבור קורא.
לכן ה-harness מתייחס לכלי שנזרק כאל נתונים, והופך את הניסוח למדיניות:
} 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 הזהיר גם מפני הצד השני, וגם לו יש מחיר. כוונו את הלולאה אל כלי שנכשל מסיבה ששום הודעה לא יכולה לתקן — קריאה שהתהליך אינו מורשה לבצע — והמודל מנסה אותה מחדש לנצח:
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, שכבה אחת למעלה: שגיאה שהמודל יכול לפעול לפיה חוזרת לתמליל, ושגיאה שהוא לא יכול צריכה לעצור את הריצה עם סיבה. תקרת התורים היא מה שעומד ביניכם לבין המקרה השני היום, וזה רצפה ולא תיקון.
שבירה רביעית: אותה קריאה, פעמיים
קישור למקטע: שבירה רביעית: אותה קריאה, פעמייםעכשיו הכשל שרוב האנשים מניחים שלא יכול לקרות. מודלים חוזרים על עצמם. בקשו מכל לולאה לרוץ מספיק זמן ותראו את אותו כלי בדיוק עם אותם ארגומנטים בדיוק בשני תורים רצופים.
נמדד מול baseline של אותה משימה בלי החזרה:
| תורים | הרצות כלי | עלות | |
|---|---|---|---|
| המשימה, בלי חזרה | 2 | 1 | $0.001396 |
| אותה משימה, קריאה אחת חוזרת | 3 | 2 | $0.002446 |
| חוזרת, עם result cache על כלים לקריאה בלבד | 3 | 1 | $0.002446 |
הקריאה המשוכפלת עלתה $0.001050 נוספים, עלייה של 75%, והנה החלק שמפתיע אנשים: cache של התוצאה לא החזיר כלום מזה. Deduplication חסך את ביצוע הכלי ולא את התור, כי עד שהקוד שלכם שם לב לחזרה, כבר שילמו למודל על כך שביקש. החיסכון אמיתי כשהכלי איטי, מוגבל בקצב, או מחויב לפי קריאה — והוא אפס בשורת החיוב שגדלה.
יש גרסה גרועה יותר. החילו את אותו cache על כלי שכותב, והקריאה השנייה פשוט לא מתרחשת בשקט:
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 key שנוצר לכל פעולה לוגית על ידי השכבה שיודעת מה הפעולה היא. עד שהכלי נושא אחד, ברירת המחדל שניתן להגן עליה היא שער הקריאה-בלבד שלמעלה: cache לקריאות, הרצת כתיבות, ולתת ל-idempotency של הכתיבה עצמה לטפל בשאר.
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;
}שבירה חמישית: הוא מוחק משהו
קישור למקטע: שבירה חמישית: הוא מוחק משהוסקריפט destructive מציג את הקבצים ואז מבקש למחוק אחד שהמשימה מעולם לא הזכירה. שום דבר בלולאה עד כה לא היה עוצר אותו.
כלי שמסומן needsApproval לא נכשל ולא ממשיך. הוא עוצר את הריצה ומחזיר שליטה, עם כל מה שאדם צריך כדי להחליט:
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 היא הסעיף הבא: בין העצירה לבין פסק הדין, ייתכן שהתהליך כבר לא קיים.
אבל קודם, המדידה שאף אחד לא מצפה לה. דחייה אינה היעדר תוצאה — לתמליל יש slot שממופה לפי tool_call_id ומשהו חייב להיכנס אליו. הריצו אותה דחייה פעמיים, כשמשנים רק מה אותו משהו אומר:
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 סותר את המציאות כי הסירוב מעולם לא נכתב במקום שבו המודל קורא. הכלל שיוצא מזה קצר: כל מה שהקוד שלכם מחליט לגבי tool call, כתבו את ההחלטה לתמליל במילים. פרק 30 חוזר לזה מצד האבטחה, שם זה ההבדל בין audit trail לבדיה.
שבירה שישית: התהליך מת
קישור למקטע: שבירה שישית: התהליך מתאישור לוקח דקות או שעות. deploy לוקח שניות. אם הריצה חיה במשתנה מקומי בתוך HTTP request, כל restart הוא ריצה אבודה וכל אישור הוא מרוץ.
לכן הריצה אינה closure. היא אובייקט רגיל שניתן לסריאליזציה — הודעות, ספירת תורים, עלות, סטטוס, הפרעה, רשימת מזהי הקריאות שאושרו — והלולאה היא פונקציה טהורה מעליו. האילוץ היחיד הזה הוא מה שהופך 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"));שאלת הנכונות אינה השמירה. היא מה קורה בדרך חזרה פנימה, והתשובה הנאיבית מחייבת אתכם פעמיים. אם התהליך מת אחרי שהמודל ביקש כלי אבל לפני שהתוצאה נכתבה, חידוש שמתחיל בקריאה שוב למודל משלם על תור שכבר יש לו — ואם הוא מתחיל בהרצת הכלים מחדש, הוא מבצע כתיבה פעמיים.
התיקון הוא לגרום ללולאה להתחיל בשאלה לתמליל מה עדיין פתוח:
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 ורק כשאין שום דבר פתוח שואלת את המודל. Resume הופך לאותו נתיב קוד כמו הנתיב הרגיל, וכך גם approval — קריאה שאושרה היא פשוט pending call שעכשיו מותר להריץ. הרגו את התהליך באמצע משימה והפעילו אותו מחדש:
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שתי הפעלות כלי על פני שני תהליכים עבור משימה שצריכה שתיים, והעלות הסופית זהה לריצה שמעולם לא קרסה. העלות מצטברת על פני ה-restart כי היא הייתה במצב, לא במשתנה.
שבירה שביעית: שלוש דקות של שקט
קישור למקטע: שבירה שביעית: שלוש דקות של שקטscan_archive לוקח כאן שלוש שניות ומייצג את הכלי שלוקח שלוש דקות בפרודקשן. שני דברים חסרים בזמן שהוא רץ: למשתמש אין מושג שמשהו קורה, וכפתור העצירה לא עושה כלום.
שניהם אותו תיקון, והוא 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"שתי מילישניות מהקליק עד העצירה, כי ה-sleep בתוך הכלי מאזין לאותו signal שה-fetch מאזין לו. העבירו אותו רק לתוך fetch וכפתור עצירה זהה מחכה שלוש שניות — אורך הכלי — והריצה ״מתבטלת״ אחרי שהעבודה שהיא ביטלה כבר הסתיימה. ביטול שלא מחווט עד הסוף הוא spinner שאומר את המילה הנכונה.
ה-trace, ולמה הוא לא log
קישור למקטע: ה-trace, ולמה הוא לא logה-harness פולט שורה אחת לכל אירוע, ואוצר המילים קטן מספיק כדי לזכור בעל פה: 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, כך שריצה שנפרסת על פני שלושה תהליכים ושני ימים היא שאילתה אחת. כל שורת turn נושאת את ספירות ה-token שלה ואת העלות הרצה, כך ש-״למה הריצה הזו עלתה ארבעים דולר״ היא שאלה שאפשר לענות עליה בדיעבד במקום משהו שאפשר לשחזר רק בתיאוריה. ו-run_stopped נושא את הסיבה, שהיא השדה שהופך ticket תמיכה לתשובה בשורה אחת: agent שנעצר בתקציב ו-agent שקרס נראים זהים מבחוץ וצריכים תגובות הפוכות.
החשבון של latency
קישור למקטע: החשבון של latencyפרק 13 מדד time to first token על חומרה שבבעלותכם. פרק 14 מדד אותו דרך socket. agent מכפיל אותו, והמכפיל הוא מספר שאף אחד לא בחר:
אותה משימה בת שלושה תורים, כשמשנים רק את ה-latency של הספק:
| latency של הספק לכל תור | זמן שעון, 3 תורים |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2,413 ms |
ה-harness עצמו תורם חמש-עשרה מילישניות לריצה בת שלושה תורים. כל השאר הוא מוכפל במספר שאינכם שולטים בו — נקבע בתוך serving scheduler שמאגד את הבקשה שלכם עם בקשות של זרים6 — ו- נבחר על ידי המודל. לכן ה-streaming של פרק 14 חשוב כאן יותר מאשר בצ׳אט ועוזר פחות: אפשר להזרים את התור האחרון, וארבעת התורים שלפניו הם שקט אלא אם ה-harness פולט התקדמות. זו גם כל הטענה בעד אירוע tool_progress למעלה — ב-agent, יחידת המשוב הכנה אינה ה-token, אלא הצעד.
אותו harness, מודל אמיתי מאחורי הפורט
קישור למקטע: אותו harness, מודל אמיתי מאחורי הפורטכל מה שלמעלה רץ מול ספק מתוסרט, מה שמוכיח את ה-harness ולא מוכיח כלום על מודלים. אז שנו שורה אחת — התפר מפרק 14, LLM_BASE_URL — וכוונו את אותו קוד בדיוק אל Qwen2.5-0.5B-Instruct מקומי עם אותם ארבעה כלים. שש משימות על אותם שלושה קבצים:
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שלושה ממצאים, והשלישי הוא הסיבה שהסעיף הזה קיים.
כל משימה ומשימה הסתיימה בדיוק בשני תורים. תקרת התורים לא הופעלה, התקציב לא הופעל, והיציאה היחידה של הלולאה הייתה שהמודל יצר פרוזה. מודל של חצי מיליארד parameters אינו עושה איטרציות; הוא עונה בנשימה השנייה שלו בין שיש לו מה שהוא צריך ובין שלא. ספירת התורים היא תכונה של המודל, לא של הלולאה שלכם.
התור הממוצע לקח 6,908 מילישניות, כך שטבלת ה-latency למעלה אינה צעצוע: בגודל הזה ריצה היפותטית של שמונה תורים היא כמעט דקה של זמן שעון בלי שום דבר על המסך.
והתשובות שגויות. הקובץ הגדול ביותר הוא errors.log; המודל הציג את הקבצים, מעולם לא קרא אותם, ובכל זאת נקב בשם אחד. המשימה הראשונה ניחשה שם קובץ, נאמר לה שהוא לא קיים, והיא הסיקה מסקנה. ה-harness פעל ללא דופי בכל שש הריצות. harness הופך agent ל-governable, לא לנכון — פרק 29 הוא הדרך לגלות מי מהם, ופרק 30 הוא מה שזה עולה כשאף אחד לא עשה זאת.
Subagents, נקראים כאן ומחויבים אחר כך
קישור למקטע: Subagents, נקראים כאן ומחויבים אחר כךכלי אחד בקטלוג יכול להריץ ריצה אחרת מאחוריו. הממשק הוא של פרק 18 — schema ו-endpoint — ו-agent שלם נכנס מאחוריו כי הממשק הזה צר:
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";
},
};שלושה דברים כבר נכונים בעשר השורות האלה, ושלושתם תוצאות של החלטות שהתקבלו למעלה: לילד יש חלון משלו, כך שהתמליל של ההורה מקבל סיכום ולא את כל מה שהילד קרא; יש לו גבולות משלו, כך שילד שבורח משליטה לא יכול לבזבז את התקציב של ההורה; והוא יורש את ה-signal, כך שעצירה אחת מבטלת את כל העץ. למה חלון נקי הוא הנקודה ולא תופעת לוואי נמצא ב-פרק 24; חמש תבניות ה-orchestration — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — וה-handoff נמצאים ב-פרק 25.
איפה ה-frameworks, ולמה הקורס הזה לא השתמש באחד
קישור למקטע: איפה ה-frameworks, ולמה הקורס הזה לא השתמש באחדאין לקרוא שום דבר מלמעלה כטיעון נגד ספריות. נמדד ב-7 בספטמבר 2026, עבור החודש שהסתיים ב-29 באוגוסט:7
| package | הורדות באותו חודש | מה הוא נותן לכם |
|---|---|---|
ai (Vercel AI SDK) | 89,385,860 | ToolLoopAgent, stopWhen, tool approval, step hooks |
@anthropic-ai/claude-agent-sdk | 41,558,352 | ה-Claude Code harness כספרייה: לולאה, sessions, hooks, הרשאות, subagents8 |
@langchain/langgraph | 12,812,815 | הלולאה כ-state graph מפורש |
langchain | 11,359,058 | chains, agents, integrations |
@openai/agents | 6,093,155 | agents, handoffs, guardrails |
@mastra/core | 5,914,502 | agents, workflows, memory |
הסיבה שהקורס הזה כותב את הלולאה ביד במקום ללמד אחד מהם מוצהרת ולא נרמזת, והיא מדידה. בשנים-עשר החודשים עד 7 בספטמבר 2026, ai פרסם 945 גרסאות ועבר מ-major 5 ל-major 7, ומחלקת ה-agent שלו עדיין מיוצאת כ-Experimental_Agent; langchain פרסם 132 גרסאות באותו חלון; @openai/agents פרסם 83 ועדיין נמצא ב-0.x, חמישה-עשר חודשים אחרי השחרור הראשון שלו.7 פרק שנכתב מול כל אחד מה-APIs האלה מתיישן בתוך עונה, והפרק הזה מתפרסם בשלושים ושלוש שפות, כך שכל מהדורה מחדש עולה לכל התרגום. מה שמתחת לכולם לא זז: לולאה, כלל עצירה, קטלוג, executor, קצת state.
ומימוש הייחוס מסכים עם הפרק הזה לגבי החלק החשוב. בגרסה 7.0.93 של ai היציאה מהלולאה אינה מספר — היא stopWhen, רשימת predicates, שספירת צעדים היא רק אחת מהן:3
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: לולאה, קטלוג, executor, חמש דרכים לצאת, ריצה שנשמרת, signal שמגיע לכלים, ו-trace עם run id על כל שורה. פרקים 24, 25, 29 ו-30 נבנים על הקובץ הזה, ו-26 עד 28 על מה שהוא יכול להגיע אליו.
נשארה לו בעיה אחת, והמדידות שלמעלה הצביעו עליה כל הדרך. הביטו שוב בטבלת הבריחה משליטה: 3,431 input tokens בשמונה תורים, 337,299 במאה. הביטו בריצה שעבדה: 204, 269, 342. כל תור שולח מחדש את כל התמליל, כך שה-context של agent מתמלא בהיסטוריה של עצמו — והמודל גרוע יותר בשימוש בקצה הרחוק של חלון ארוך מאשר בקצה הקרוב, ולכן agent טוב בתור חמש הוא מבולבל בתור ארבעים.
תקרת תורים לא מתקנת את זה. היא רק עוצרת אתכם מלשלם כדי לראות את זה קורה. מה שמתקן את זה הוא להחליט, בכל תור ותור, אילו tokens ראויים לחלון: מה לדחוס, מה להעביר החוצה להערה שה-agent יכול לשלוף, מה למסור ל-subagent עם חלון נקי, ואילו הגדרות כלים שוות את המס הקבוע שלהן. פרק 24 מודד לאן החלון באמת הולך — וההפתעה היא שזה לא השיחה.
מקורות ושיטה
קישור למקטע: מקורות ושיטהכל מספר בפרק הזה הגיע משני השרתים שתוארו למעלה, על Node 22 דרך ממשק loopback: ספק מתוסרט שסופר 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 למה שמודלים נוכחיים עושים.
הפניות
קישור למקטע: הפניות-
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 traces ופעולות שהלולאה מממשת, ומקור התצפית שפעולה מאפשרת למודל ״לטפל בחריגות״ — וזה בדיוק מה שטבלת שגיאות הכלים למעלה מודדת. ↩
-
Sumers, T. R., Yao, S., Narasimhan, K. and Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). הטיפול הפורמלי במה שהלולאה למעלה עושה באופן לא פורמלי: רכיבי זיכרון מודולריים, מרחב פעולה מובנה שנפרש על זיכרון פנימי וסביבות חיצוניות, ו-״תהליך קבלת החלטות מוכלל לבחירת פעולות״. קראו אותו בשביל אוצר המילים שחסר למונח התעשייתי — בפרט ההפרדה בין working, episodic, semantic ו-procedural memory, שהצל המעשי שלה הוא טבלת שלושת המאגרים בפרק 24. ↩
-
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מוצגת בלי פרמטר הטיפוס השני שלה (RUNTIME_CONTEXT extends Context = Context), שהוא ההשמטה היחידה בקטע, כמו גם הצורה שלstopWhen?: Arrayable<StopCondition<...>>עלgenerateTextו-streamText. אותו קובץ מצהיר עלtoolApproval,ToolApprovalStatus,prepareStepו-repairToolCall, כלומר מימוש הייחוס הגיע באופן עצמאי ל-approval gates, הכנה לכל צעד ותיקון שגיאות. ↩ ↩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). התקציר קורא לארטיפקט ״evaluation framework״ של 2,294 בעיות ולעולם אינו משתמש במילה ״harness״; ה-README של הפרויקט עצמו (
github.com/SWE-bench/SWE-bench, נקרא ב-7 בספטמבר 2026) משתמש בה חמש פעמים, תמיד בתור ״evaluation harness״, ונקודת הכניסה היאpython -m swebench.harness.run_evaluation. זו המשמעות האחרת של המילה: שלד שמחזיק את ה-agent במקום ומדרג אותו, לא הלולאה שמריצה אותו. ↩ -
Anthropic, Building effective agents, 19 בדצמבר 2024,
anthropic.com/engineering/building-effective-agents, נקרא ב-7 בספטמבר 2026. המודל המוגבר כאבן הבניין, ה-agent כ-LLM ״שמשתמש בכלים על בסיס משוב סביבתי בלולאה״, וההמלצה על תנאי עצירה ״כגון מספר איטרציות מרבי״ כדי לשמור על שליטה. פרק 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 שמאגד את הבקשה שלכם עם בקשות של זרים ומנהל את KV cache של פרק 13. כדאי לדעת שהוא קיים בדיוק כי הוא לא שלכם: ה-latency שה-harness שלכם מכפיל נקבע בתוכו, ושום עבודה על הלולאה שלכם לא מזיזה אותו. ↩
-
ספירות הורדות מרשם npm,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, חלון מפורש ולא חלוןlast-monthמתגלגל, והיסטוריות שחרור מתוךregistry.npmjs.org/<package>; שניהם תושאלו ב-7 בספטמבר 2026. ספירות השחרור הן מספר הגרסאות שפורסמו בשנים-עשר החודשים עד תאריך זה, כולל canary builds:ai945 (האחרונה 7.0.93 ב-2026-09-04, עם גרסאות major 5, 6 ו-7 כולן בתוך החלון),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 ארוז כספרייה — לולאת agent, כלי קבצים ו-shell מובנים, context management, sessions, hooks, הרשאות ו-subagents — מתועד ב-code.claude.com/docs/en/agent-sdk. זה הדבר הקרוב ביותר לתיאור שפורסם של כל מנגנון שהפרק הזה בונה ביד, ושווה לקרוא אותו לצד המימוש שלכם עבור החלקים שהוא נותן להם שם והפרק הזה רק מרמז עליהם. ↩