Створюємо agent harness: цикл і п’ять способів вийти з нього
15-рядковий цикл, що працює з першої спроби, а потім сім навмисних зламів — від runaway у 77 разів дорожчого за обмежений.
На цій сторінці
Почнімо з чесної частини, бо більше ніхто цього не скаже: «harness» — це жаргон, а не стандарт. Немає ні специфікації, ні комітету, ні еталонного визначення. Чотири праці, на які посилається цей розділ — ReAct,1 CoALA,2 SWE-bench і vLLM, — у своїх анотаціях не використовують це слово жодного разу. Найбільш завантажувана реалізація цієї штуки, пакет Vercel ai із 89,4 мільйона завантажень на місяць, теж його не використовує: рядок harness зустрічається нуль разів у 397 КБ декларацій типів, що постачаються з версією 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 — для клієнта: deadlines, тріаж статусів, скасування, idempotency keys і техніка mock provider, яку ми знову використаємо тут.
- Розділ 16 — для арифметики: вхідні tokens ростуть із квадратом розмови, а тарифи нижче — ті самі, що були прочитані там 6 вересня 2026 року.
- Розділ 18 — для каталогу інструментів: schema, яку бачить модель, endpoint, якого вона ніколи не бачить, і правило, що помилки — це context, а не exceptions.
- Розділ 22 — для циклу, який цей розділ успадковує, і для двох опублікованих визначень «agent», що суперечать одне одному.
Тензорів тут немає. Це другий вузол залежностей курсу: розділи 24, 25, 29 і 30 працюють на файлі нижче, а 26–28 будуються на тому, до чого він може дотягнутися.
Provider, який можна скриптувати
Посилання на розділ: Provider, який можна скриптуватиРозділ 14 не можна було написати проти справжнього provider, бо ви не можете попросити його видати 429 у вибраний момент. У цьому розділі та сама проблема має іншу форму: ви не можете попросити реальну модель runaway, або на вимогу й відтворювано двічі поспіль запросити той самий інструмент.
Тому перша програма — це scripted provider: 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);Дві лінії несуть дизайн. Індекс ходу виводиться з розмови, а не зберігається у змінній, тому provider є stateless, і run можна вбити та відновити проти нього. А recover читає результати інструментів перед рішенням: scripted модель, яка читає власний 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, який приходить у місці, де ніхто нічого не вводив. Решта цього розділу — про те, що відбувається, коли цей рядок не перестає рости.
Злам перший: задача, яка ніколи не закінчується
Посилання на розділ: Злам перший: задача, яка ніколи не закінчуєтьсяСпрямуйте той самий цикл на скрипт runaway — модель, що просить інструмент на кожному ході й ніколи не видає prose, — і позначений return ніколи не спрацює. Іншого виходу немає. Програма працює, доки не помре процес або кредитна картка.
Виправлення — один рядок. Це перший контроль, який рекомендує література,5 і всі зрештою його пишуть. Майже ніхто не робить іншого: не вимірює, скільки він вартий:
| обмеження ходів | виклики моделі | вхідні 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. Вхідні tokens зросли з 88,649 до 337,299, тобто у 3,8 раза, бо хід несе із собою кожен попередній хід, а сума дорівнює . Ліміт ходів — не лінійний регулятор. Це регулятор квадратного кореня вашого найгіршого випадку, тому підняти його з 20 до 100 «про всяк випадок» — рішення, яке варто порахувати до того, як ви його ухвалите.
Злам другий: ліміт ходів не є лімітом грошей
Посилання на розділ: Злам другий: ліміт ходів не є лімітом грошейПроблема з лімітом ходів у тому, що хід не має сталої ціни. Двадцять ходів на короткому transcript вище коштували $0.038. Двадцять ходів із каталогом на 200 інструментів, набором retrieved документів і сорока повідомленнями історії коштують у сотні разів більше, а ліміт цього не знає. Оператор хоче обмежити рахунок.
Тож цикл рахує гроші, використовуючи computeCost із розділу 16 проти тарифів, прочитаних там: $2.00 за мільйон вхідних tokens і $12.00 за мільйон вихідних для моделі, ціна якої використовується протягом усього курсу:
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);Той самий runaway-скрипт, жодного ліміту ходів, три бюджети:
| бюджет | досягнуті ходи | фактично витрачено |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $0.205398 |
Дві речі варто назвати. По-перше, бюджет щоразу купує іншу кількість ходів — у цьому й сенс: він обмежує те, що турбує оператора, і дозволяє кількості ходів впасти там, куди її ставить transcript. По-друге, кожен рядок перевищує бюджет. Бюджет був $0.010, а витрачено $0.010780, бо перевірка виконується перед ходом, а ціна ходу невідома, доки він не закінчиться. Ви не можете обмежити витрати точно; ви можете обмежити їх із похибкою в ціну одного ходу. Скажіть це в інтерфейсі, а не вдавайте, і поставте перевірку перед викликом, щоб перевищення було одним ходом, а не двома.
П’ять способів вийти з циклу, а не один
Посилання на розділ: П’ять способів вийти з циклу, а не одинТепер у циклу три виходи, і форма решти розділу вже видима. Production run завершується рівно одним із п’яти способів, і це не варіації одного й того самого:
| як він завершується | хто вирішив | що має зробити caller |
|---|---|---|
| модель перестала просити | модель | прочитати відповідь |
| ліміт ходів | ви, наперед | підняти ліміт або прийняти частковий результат |
| бюджет вичерпано | ви, наперед | затвердити більше грошей або прийняти частковий результат |
| помилка, яку не можна retry | provider або інструмент | виправити deployment; тріаж із розділу 14 вирішує |
| втрутилася людина | людина | чекати на verdict, потім відновити |
Зводити це до одного boolean — найпоширеніша помилка дизайну в цьому файлі, і вона дорога в конкретний спосіб: три з п’яти станів відновлювані, а два — ні. Agent, який уперся в ліміт ходів, має valid transcript, реальний частковий результат і наступний крок; 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 завершився твердженням без числа: передайте помилку інструмента назад моделі як результат інструмента, а не кидайте її, і модель зазвичай виправиться сама. Ось число.
Одна помилка, три policies. Scripted модель вгадує файл, якого не існує; інструмент кидає 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 раза більше за перший і єдиний відповідає на запитання. А другий рядок — найцікавіший, бо саме так робить більшість кодових баз: помилку спіймали, цикл вижив, моделі сказали, що щось зламалося, але не що саме, і вона чемно здалася. Різниця між другим і третім рядками — не error handling. Це речення, написане для читача.
Тому harness трактує thrown tool як дані й робить формулювання 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 також попереджав про інший бік, і він теж має ціну. Спрямуйте цикл на інструмент, який падає з причини, яку жодне повідомлення не виправить, — читання, яке процесу не дозволено виконати, — і модель retry його безкінечно:
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 раза більша вартість за run, що відновився після виправної помилки, і нічого наприкінці. Помилки — це context; постійна помилка — це context, який отруює решту run. Відмінність — це тріаж статусів із розділу 14, перенесений на один шар вище: помилка, на яку модель може діяти, повертається в transcript, а помилка, на яку вона не може діяти, має зупинити run із причиною. Ліміт ходів — це те, що сьогодні стоїть між вами й другим випадком; це підлога, а не виправлення.
Злам четвертий: той самий виклик двічі
Посилання на розділ: Злам четвертий: той самий виклик двічіТепер відмова, яку більшість людей вважає неможливою. Моделі повторюються. Попросіть будь-який цикл працювати достатньо довго, і ви побачите той самий інструмент із тими самими аргументами на двох послідовних ходах.
Виміряно проти baseline тієї самої задачі без повтору:
| ходи | запуски інструмента | вартість | |
|---|---|---|---|
| задача, без повтору | 2 | 1 | $0.001396 |
| та сама задача, один виклик повторено | 3 | 2 | $0.002446 |
| повторено, з result cache на read-only інструментах | 3 | 1 | $0.002446 |
Дубльований виклик коштував додаткові $0.001050, зростання на 75 %, і ось частина, яка дивує людей: caching результату не повернув нічого з цього. Deduplication зекономив виконання інструмента, а не хід, бо на момент, коли ваш код помітив повтор, моделі вже заплатили за запит. Економія реальна, коли інструмент повільний, обмежений rate limit або тарифікується за виклик, — і нульова в рядку рахунку, який виріс.
Є гірша версія. Застосуйте той самий 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"]Який із цих варіантів правильний? Жоден — і це відомо. Protocol каже, що це два виклики: вони несуть два різні значення tool_call_id. Аргументи кажуть, що це може бути один. Harness, який вирішує порівнянням рядків аргументів, одного дня проковтне другий із двох однакових, навмисних платежів — а розділ 14 уже назвав єдиний механізм, який розв’язує це чесно: idempotency key, згенерований для кожної логічної операції шаром, який знає, що це за операція. Доки інструмент не несе його, захищений default — read-only gate вище: cache для reads, execute для writes, а решту нехай обробляє власна 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;
}Злам п’ятий: він щось видаляє
Посилання на розділ: Злам п’ятий: він щось видаляєСкрипт destructive перелічує файли, а потім просить видалити один, якого задача ніколи не згадувала. Ніщо в циклі досі цього не зупинило б.
Інструмент, позначений needsApproval, не падає й не продовжує виконання. Він зупиняє 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 процес може вже не існувати.
Але спершу вимірювання, якого ніхто не очікує. Відхилення — це не відсутність результату: transcript має слот із ключем 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."Нічого не було видалено в жодному run, а в другому користувачу сказали, що було. Permission system спрацювала ідеально; звіт — брехня. Це той самий механізм, що й у таблиці помилок інструмента, але в місці, де ставки значно вищі: людина сказала «ні», дію правильно заблоковано, а summary agent суперечить реальності, бо відмову ніколи не записали туди, де її читає модель. Правило, яке з цього випливає, коротке: що б ваш код не вирішив щодо tool call, запишіть це рішення в transcript словами. Розділ 30 повертається до цього з боку безпеки, де це різниця між audit trail і fiction.
Злам шостий: процес помирає
Посилання на розділ: Злам шостий: процес помираєApproval займає хвилини або години. Deploy займає секунди. Якщо run живе в локальній змінній усередині HTTP request, кожен restart — це втрачений run, а кожне approval — це race.
Тож run — не closure. Це звичайний serialisable object: messages, turn count, cost, status, interruption, список approved call ids, — а цикл є чистою функцією над ним. Саме це одне обмеження робить 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 — не в збереженні. Воно в тому, що стається на шляху назад, і наївна відповідь бере з вас оплату двічі. Якщо процес помер після того, як модель попросила інструмент, але до того, як результат було записано, 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));
}Кожна ітерація спершу drains pending і питає модель лише тоді, коли нічого outstanding немає. Resume стає тим самим code path, що й звичайний перебіг, і approval теж: approved call — це просто 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Два виконання інструментів у двох процесах для задачі, якій потрібні два, а фінальна вартість ідентична run, який ніколи не падав. Cost накопичується через restart, бо був у state, а не у змінній.
Злам сьомий: три хвилини тиші
Посилання на розділ: Злам сьомий: три хвилини тишіscan_archive тут займає три секунди й замінює інструмент, який у production займає три хвилини. Поки він працює, бракує двох речей: користувач не має уявлення, що щось відбувається, а кнопка Stop нічого не робить.
Обидва браки мають одне виправлення, і це 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, і та сама кнопка Stop чекатиме три секунди — довжину інструмента, — а run «скасується» після того, як робота, яку він скасовував, уже завершилася. Cancellation, не прокладене до самого низу, — це spinner, який показує правильне слово.
Trace і чому це не log
Посилання на розділ: Trace і чому це не logHarness emits один рядок на подію, а 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, що тягнеться через три процеси й два дні, — це один query. Кожен рядок turn несе власні token counts і running cost, тому на питання «чому цей run коштував сорок доларів» можна відповісти постфактум, а не відтворити лише теоретично. А run_stopped несе reason — поле, яке перетворює support ticket на відповідь в один рядок: agent, що зупинився на бюджеті, і agent, що crashed, виглядають зовні однаково й потребують протилежних реакцій.
Арифметика latency
Посилання на розділ: Арифметика latencyРозділ 13 вимірював time to first token на hardware, яким ви володієте. Розділ 14 вимірював його через socket. Agent його множить, і множник — це число, яке ніхто не обирав:
Та сама задача на три ходи, змінюється лише latency provider:
| latency provider на хід | wall clock, 3 ходи |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2,413 ms |
Сам harness додає п’ятнадцять мілісекунд до run на три ходи. Усе інше — це , помножене на число, яке ви не контролюєте, — встановлене всередині serving scheduler, що batch ваш request із request незнайомців6, — а обирає модель. Саме тому streaming із розділу 14 тут важливіший, ніж у chat, і допомагає менше: ви можете stream фінальний хід, а чотири ходи перед ним — тиша, якщо harness не emits progress. Це також увесь аргумент за подію tool_progress вище: в agent чесна одиниця feedback — не token, а step.
Той самий harness, реальна модель за портом
Посилання на розділ: Той самий harness, реальна модель за портомУсе вище працювало проти scripted provider, що доводить harness і нічого не доводить про моделі. Тож змініть один рядок — seam із розділу 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Три висновки, і третій — причина існування цього розділу.
Кожна задача завершилася рівно за два ходи. Ліміт ходів не спрацював, бюджет не спрацював, і єдиним виходом циклу було те, що модель produced prose. Модель на пів мільярда parameters не ітерує; вона відповідає на другому подиху незалежно від того, чи має потрібне. Кількість ходів — властивість моделі, а не вашого циклу.
Середній хід тривав 6,908 мілісекунд, тому таблиця latency вище — не іграшка: за такого розміру гіпотетичний run на вісім ходів — це майже хвилина wall clock без нічого на екрані.
І відповіді неправильні. Найбільший файл — errors.log; модель перелічила файли, ніколи їх не прочитала й усе одно назвала один. Перша задача вгадала ім’я файла, дізналася, що його не існує, і завершила. Harness бездоганно виконався в усіх шести run. Harness робить agent керованим, а не правильним — розділ 29 показує, як з’ясувати, що саме, а розділ 30 — скільки коштує, коли цього ніхто не зробив.
Subagents, названі тут і тарифіковані пізніше
Посилання на розділ: Subagents, названі тут і тарифіковані пізнішеОдин інструмент у каталозі може мати за собою інший run. Інтерфейс — із розділу 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";
},
};Три речі вже правильні в цих десяти рядках, і всі три — наслідки рішень вище: child має власне window, тому transcript parent отримує summary, а не все, що читав child; він має власні limits, тому runaway child не може витратити budget parent; і він успадковує signal, тому один Stop скасовує дерево. Чому clean window — це суть, а не побічний ефект, пояснює розділ 24; п’ять orchestration patterns — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — і передавання описані в розділі 25.
Де frameworks і чому цей курс не використав жодного
Посилання на розділ: Де frameworks і чому цей курс не використав жодногоНіщо з написаного вище не слід читати як аргумент проти libraries. Виміряно 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 як library: цикл, sessions, hooks, permissions, 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 class досі експортується як Experimental_Agent; langchain опублікував 132 версії за те саме вікно; @openai/agents опублікував 83 і досі на 0.x, через п’ятнадцять місяців після першого release.7 Розділ, написаний проти будь-якого з цих API, застаріває за сезон, а цей публікується тридцятьма трьома мовами, тому кожне перевидання коштує всього перекладу. Те, що лежить під усіма ними, не рухається: цикл, stopping rule, каталог, executor, трохи state.
І reference implementation погоджується з цим розділом у частині, яка має значення. У ai версії 7.0.93 вихід із циклу — не число, а stopWhen, список predicates, серед яких 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, п’ять способів виходу, persisted run, signal, що доходить до інструментів, і trace з run id у кожному рядку. Розділи 24, 25, 29 і 30 будуються на цьому файлі, а 26–28 — на тому, до чого він може дотягнутися.
У нього залишилася одна проблема, і вимірювання вище весь час на неї вказували. Подивіться ще раз на таблицю runaway: 3,431 вхідних tokens на восьми ходах, 337,299 на ста. Подивіться на working run: 204, 269, 342. Кожен хід повторно надсилає весь transcript, тож context agent заповнюється власною історією — і модель гірше використовує дальній кінець довгого window, ніж ближній, тому хороший agent на п’ятому ході стає розгубленим на сороковому.
Ліміт ходів цього не виправляє. Він просто зупиняє вас від оплати перегляду того, як це стається. Виправляє це рішення на кожному окремому ході: які tokens заслуговують на window; що compact; що винести в note, який agent може fetch; що передати subagent із clean window; і які tool definitions варті свого постійного податку. Розділ 24 вимірює, куди насправді йде window — і сюрприз у тому, що це не розмова.
Джерела й метод
Посилання на розділ: Джерела й методКожне число в цьому розділі вийшло з двох server, описаних вище, на Node 22 через loopback interface: scripted provider, що рахує tokens із encoding o200k_base, і Qwen/Qwen2.5-0.5B-Instruct за endpoint тієї самої форми, greedy decoding, на CPU. Costs обчислено з виміряних token counts за тарифами, які розділ 16 прочитав 6 вересня 2026 року: $2.00 за мільйон вхідних tokens і $12.00 за мільйон вихідних, — і жоден request у цьому розділі не пішов у paid 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 traces і actions, яке реалізує цикл, і джерело спостереження, що acting дозволяє моделі «handle exceptions», — саме це вимірює таблиця tool-error вище. ↩
-
Sumers, T. R., Yao, S., Narasimhan, K. and Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). Формальний виклад того, що цикл вище робить неформально: modular memory components, structured action space, що охоплює internal memory і external environments, і «generalized decision-making process to choose actions». Читайте це заради vocabulary, якого бракує галузевому терміну, — зокрема розділення 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.ts7 вересня 2026 року. Файл на 397 КБ містить нуль входжень рядкаharness. Agent class —declare class ToolLoopAgent, експортований і якToolLoopAgent, і якExperimental_Agent;declare function isStepCount(stepCount: number)— експортований якstepCountIs— процитовано дослівно вище;type StopConditionпоказано без другого type parameter (RUNTIME_CONTEXT extends Context = Context), що є єдиним скороченням у excerpt, як і shapestopWhen?: Arrayable<StopCondition<...>>наgenerateTextіstreamText. Той самий файл оголошуєtoolApproval,ToolApprovalStatus,prepareStepіrepairToolCall, тобто reference implementation незалежно прийшла до approval gates, per-step preparation і 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). Анотація називає артефакт «evaluation framework» із 2,294 problems і ніколи не використовує слово «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, що «using tools based on environmental feedback in a loop», і рекомендація stopping conditions «such as a maximum number of iterations» для збереження control. Розділ 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, який batch ваш request із request незнайомців і керує KV cache з розділу 13. Варто знати, що він існує, саме тому, що він не ваш: latency, яку множить ваш harness, встановлюється всередині нього, і жодна робота над вашим циклом її не посуне. ↩
-
Кількість завантажень із npm registry,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, явне window замість rollinglast-month, і release histories зregistry.npmjs.org/<package>; обидва запитано 7 вересня 2026 року. Release counts — це кількість версій, опублікованих за дванадцять місяців до цієї дати, включно з canary builds:ai945 (latest 7.0.93 2026-09-04, причому major versions 5, 6 і 7 усі з’явилися всередині window),langchain132 (latest 1.5.10 2026-08-20),@openai/agents83 (latest 0.17.0 2026-08-19, first published 2025-06-03). ↩ ↩2 -
Claude Agent SDK (
@anthropic-ai/claude-agent-sdk) — це Claude Code harness, упакований як library: agent loop, built-in file and shell tools, context management, sessions, hooks, permissions і subagents, — задокументований наcode.claude.com/docs/en/agent-sdk. Це найближче до опублікованого опису кожного механізму, який цей розділ будує вручну, і його варто читати поруч із власною реалізацією заради частин, які він називає, а цей розділ лише позначає. ↩