Собираем 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 — контейнеризированный каркас, который применяет патч и запускает тесты, — а его Python-модуль буквально называется swebench.harness.run_evaluation.4
Так что два разных предмета делят одно имя. Evaluation harness удерживает agent на месте и оценивает его. agent harness — это программа, которая запускает agent: вызывает модель, выполняет то, что модель запрашивает, решает, когда остановиться, и хранит состояние между шагами. Эта глава строит второй вариант: меньше чем за двести строк TypeScript и вообще без framework.
Сам цикл занимает пятнадцать строк и работает с первой попытки. Всё после этого — способы из него выйти.
Показать детали
Что этой главе нужно из предыдущих.
- Глава 14 — для клиента: дедлайны, триаж статусов, отмена, ключи идемпотентности и техника mock provider, снова используемая здесь.
- Глава 16 — для арифметики: input tokens растут с квадратом разговора, а тарифы ниже — те, что были прочитаны там 6 сентября 2026 года.
- Глава 18 — для каталога инструментов: schema, которую видит модель, endpoint, которого она никогда не видит, и правило, что ошибки — это context, а не исключения.
- Глава 22 — для цикла, который наследует эта глава, и для двух опубликованных определений «agent», не согласных друг с другом.
Здесь нет tensors. Это второй узел зависимостей курса: главы 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 не имеет состояния, и запуск можно убить, а затем возобновить против него. А recover читает результаты инструментов перед решением: scripted model, которая читает собственную стенограмму, — минимум, нужный для измерения того, дал ли ей 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 — модель, которая каждый ход просит инструмент и никогда не выдаёт прозу, — и помеченный 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 инструментов, набором retrieved documents и сорока сообщениями истории стоят в сотни раз больше, и лимит об этом не знает. Оператор хочет ограничить счёт.
Поэтому цикл считает деньги, используя 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);Тот же runaway script, вообще без лимита ходов, три бюджета:
| бюджет | достигнуто ходов | фактически потрачено |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $0.205398 |
Стоит назвать две вещи. Во-первых, бюджет каждый раз покупает разное число ходов, и в этом смысл: он ограничивает то, что важно оператору, а счётчику ходов позволяет упасть туда, куда его ставит стенограмма. Во-вторых, каждая строка перерасходует. Бюджет был $0.010, а потрачено $0.010780, потому что проверка выполняется перед ходом, а цена хода неизвестна, пока он не закончится. Нельзя ограничить расходы абсолютно точно; можно ограничить их с точностью до стоимости одного хода. Скажите это в интерфейсе, а не притворяйтесь, и ставьте проверку перед вызовом, чтобы перерасход был один ход, а не два.
Пять способов выйти из цикла, а не один
Ссылка на раздел: Пять способов выйти из цикла, а не одинК этому моменту у цикла три выхода, и форма оставшейся главы уже видна. Продакшен-запуск заканчивается ровно одним из пяти способов, и это не вариации одного и того же:
| как он заканчивается | кто решил | что должен сделать caller |
|---|---|---|
| модель перестала просить | модель | прочитать ответ |
| лимит ходов | вы, заранее | поднять лимит или принять частичный результат |
| бюджет исчерпан | вы, заранее | одобрить больше денег или принять частичный результат |
| ошибка, которую нельзя retry | provider или инструмент | исправить deployment; решает триаж из главы 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 закончилась утверждением без числа: верните ошибку инструмента модели как результат инструмента, а не бросайте её, и модель обычно исправится сама. Вот число.
Один сбой, три политики. Scripted model угадывает файл, которого не существует; инструмент бросает 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 раза больше первой и единственная отвечает на вопрос. А вторая строка интересна потому, что именно так поступает большинство codebases: ошибка поймана, цикл выжил, модели сказали, что что-то сломалось, но не что именно, и она вежливо сдалась. Разница между строками два и три — не error handling. Это предложение, написанное для читателя.
Поэтому 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 |
| повтор, с кэшем результата на read-only инструментах | 3 | 1 | $0.002446 |
Дублированный вызов стоил дополнительные $0.001050, рост на 75 %, и вот что удивляет людей: кэширование результата не вернуло ничего. Deduplication сэкономила выполнение инструмента, но не ход, потому что к тому моменту, когда ваш код замечает повтор, модели уже заплатили за просьбу. Экономия реальна, когда инструмент медленный, rate-limited или тарифицируется за вызов, — и равна нулю в той статье расходов, которая выросла.
Есть версия хуже. Примените тот же кэш к инструменту, который пишет, и второй вызов тихо не произойдёт:
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, сгенерированный на логическую операцию слоем, который знает, что это за операция. Пока инструмент не несёт такой ключ, защищаемая по умолчанию позиция — read-only gate выше: кэшировать чтения, выполнять записи и позволить собственной идемпотентности записи обработать остальное.
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, — следующий раздел: между остановкой и вердиктом процесса может уже не существовать.
Но сначала измерение, которого никто не ожидает. Отклонение — не отсутствие результата: в стенограмме есть слот с ключом 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."В обоих запусках ничего не было удалено, а во втором пользователю сказали, что было. Permission system сработала идеально; отчёт — ложь. Это тот же механизм, что и в таблице ошибок инструментов, только в месте куда важнее: человек сказал «нет», действие корректно заблокировано, а summary agent противоречит реальности, потому что отказ не был записан там, где модель читает. Правило отсюда короткое: что бы ваш код ни решил о вызове инструмента, запишите это решение в стенограмму словами. Глава 30 возвращается к этому со стороны безопасности, где это разница между audit trail и вымыслом.
Поломка шестая: процесс умирает
Ссылка на раздел: Поломка шестая: процесс умираетApproval занимает минуты или часы. Deploy занимает секунды. Если запуск живёт в локальной переменной внутри HTTP-запроса, каждый рестарт — потерянный запуск, а каждый approval — гонка.
Поэтому запуск — не closure. Это обычный serialisable object: сообщения, счётчик ходов, стоимость, статус, interruption, список approved call ids — а цикл является pure function над ним. Одно это ограничение делает 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"));Вопрос корректности — не в сохранении. Он в том, что происходит при возвращении, и наивный ответ выставляет вам двойной счёт. Если процесс умер после того, как модель попросила инструмент, но до записи результата, resume, начинающийся с нового вызова модели, платит за ход, который у него уже есть, — а если он начинается с повторного запуска инструментов, то дважды выполняет запись.
Исправление — заставить цикл начинать с вопроса стенограмме: что ещё 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));
}Каждая итерация сначала опустошает pending и спрашивает модель только когда outstanding больше ничего нет. Resume становится тем же code path, что и обычный ход, как и 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Два выполнения инструментов в двух процессах для задачи, которой нужны два, а итоговая стоимость идентична запуску, который никогда не падал. Стоимость накапливается через рестарт, потому что она была в state, а не в переменной.
Поломка седьмая: три минуты тишины
Ссылка на раздел: Поломка седьмая: три минуты тишиныscan_archive занимает здесь три секунды и заменяет инструмент, который в продакшене занимает три минуты. Пока он работает, не хватает двух вещей: пользователь не понимает, что что-то происходит, а кнопка Stop ничего не делает.
У обеих проблем одно исправление, и это AbortSignal из главы 14, протянутый на уровень глубже. Сигнал не только для 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 внутри инструмента слушает тот же сигнал, что и fetch. Протяните его только в fetch, и та же кнопка Stop будет ждать три секунды — длину работы инструмента, — а запуск «отменится» после того, как отменяемая работа уже закончилась. Cancellation, не проложенная до самого низа, — это spinner, который говорит правильное слово.
Trace, и почему это не log
Ссылка на раздел: Trace, и почему это не logharness emits одну строку на событие, а словарь достаточно мал, чтобы его запомнить: 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, так что запуск, растянутый на три процесса и два дня, — это один query. Каждая строка turn несёт собственные счётчики tokens и текущую стоимость, так что на вопрос «почему этот запуск стоил сорок долларов» можно ответить постфактум, а не только теоретически воспроизвести. А run_stopped несёт причину — поле, которое превращает support ticket в ответ на одну строку: agent, остановившийся на бюджете, и agent, который crashed, снаружи выглядят одинаково, но требуют противоположных реакций.
Арифметика latency
Ссылка на раздел: Арифметика latencyГлава 13 измеряла time to first token на вашем железе. Глава 14 измеряла его через socket. agent умножает его, и множитель — число, которое никто не выбирал:
Та же трёхходовая задача, меняется только latency provider:
| latency provider на ход | wall clock, 3 хода |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2,413 ms |
Сам harness добавляет пятнадцать миллисекунд к трёхходовому запуску. Всё остальное — , умноженное на число, которое вы не контролируете, — заданное внутри serving scheduler, который batching ваш запрос с запросами незнакомцев6, — а выбирает модель. Поэтому streaming из главы 14 важнее здесь, чем в chat, и помогает меньше: вы можете stream последний ход, а четыре хода до него — тишина, если harness не emits progress. Это также весь аргумент в пользу события tool_progress выше: в agent честная единица обратной связи — не 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Три вывода, и третий — причина существования этого раздела.
Каждая отдельная задача завершилась ровно за два хода. Лимит ходов не сработал, бюджет не сработал, а единственным выходом цикла стала проза модели. Модель на полмиллиарда параметров не итерирует; она отвечает на втором дыхании, независимо от того, есть ли у неё то, что нужно. Число ходов — свойство модели, а не вашего цикла.
Средний ход занял 6,908 миллисекунд, так что таблица latency выше — не игрушка: при таком размере гипотетический восьмиходовый запуск — почти минута wall clock без ничего на экране.
И ответы неверны. Самый большой файл — errors.log; модель перечислила файлы, не прочитала их и всё равно назвала один. В первой задаче она угадала имя файла, ей сказали, что его не существует, и она сделала вывод. harness во всех шести запусках сработал безупречно. harness делает agent управляемым, а не правильным — глава 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";
},
};Три вещи уже правильны в этих десяти строках, и все три являются следствием решений выше: у child есть собственное window, поэтому стенограмма parent получает summary, а не всё, что child прочитал; у него есть собственные limits, поэтому runaway child не может потратить бюджет parent; и он наследует signal, поэтому один Stop отменяет всё дерево. Почему чистое window — смысл, а не побочный эффект, объясняет глава 24; пять orchestration patterns — 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, approval инструментов, step hooks |
@anthropic-ai/claude-agent-sdk | 41,558,352 | Claude Code harness как библиотека: цикл, 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 всё ещё экспортируется как Experimental_Agent; langchain опубликовал 132 версии за то же окно; @openai/agents опубликовал 83 и всё ещё на 0.x через пятнадцать месяцев после первого релиза.7 Глава, написанная под любой из этих API, устаревает за сезон, а эта публикуется на тридцати трёх языках, так что каждое переиздание стоит всей переведённой версии. То, что лежит под ними всеми, не движется: цикл, stopping rule, каталог, executor, немного state.
И reference implementation согласна с этой главой в главном. В ai версии 7.0.93 выход из цикла — не число, а stopWhen, список predicates, из которых счётчик steps — лишь один: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, пять выходов, persisted run, signal, доходящий до инструментов, и trace с run id в каждой строке. Главы 24, 25, 29 и 30 строятся на этом файле, а 26–28 — на том, до чего он может дотянуться.
Осталась одна проблема, и измерения выше всё время на неё указывали. Посмотрите ещё раз на таблицу runaway: 3,431 input tokens на восьми ходах, 337,299 на сотне. Посмотрите на рабочий запуск: 204, 269, 342. Каждый ход заново отправляет всю стенограмму, поэтому context agent заполняется собственной историей — а модель хуже использует дальний конец длинного window, чем ближний, вот почему хороший agent на пятом ходу становится растерянным на сороковом.
Лимит ходов это не исправляет. Он просто не даёт вам платить за просмотр происходящего. Исправляет это решение на каждом отдельном ходе: какие tokens заслуживают window, что compact, что вынести в заметку, которую agent сможет fetch, что отдать subagent с чистым window, и какие определения инструментов стоят своего постоянного налога. Глава 24 измеряет, куда window реально уходит, — и сюрприз в том, что не в разговор.
Источники и метод
Ссылка на раздел: Источники и методКаждое число в этой главе получено из двух серверов, описанных выше, на Node 22 через loopback interface: scripted provider, считающий tokens с encoding o200k_base, и Qwen/Qwen2.5-0.5B-Instruct за endpoint той же формы, greedy decoding, на CPU. Стоимости вычислены по измеренным token counts на тарифах, которые глава 16 прочитала 6 сентября 2026 года: $2.00 за миллион input tokens и $12.00 за миллион output; ни один request в этой главе не ушёл в paid endpoint. Ответы локальной модели — ответы маленькой модели; читайте их как evidence о цикле, который в обоих случаях идентичен, а не как benchmark того, что делают current models.
Сноски
Ссылка на раздел: Сноски-
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, которое реализует цикл, и источник наблюдения, что действие позволяет модели «handle exceptions», — ровно это измеряет таблица ошибок инструментов выше. ↩
-
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.ts7 сентября 2026 года. Файл размером 397 КБ содержит ноль вхождений строкиharness. Класс agent —declare class ToolLoopAgent, экспортируется и какToolLoopAgent, и какExperimental_Agent;declare function isStepCount(stepCount: number)— экспортируемый какstepCountIs— дословно процитирован выше;type StopConditionпоказан без второго type parameter (RUNTIME_CONTEXT extends Context = Context), что является единственным пропуском во фрагменте, как и формаstopWhen?: Arrayable<StopCondition<...>>наgenerateTextиstreamText. Тот же файл объявляетtoolApproval,ToolApprovalStatus,prepareStepиrepairToolCall, то есть 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 задач и никогда не использует слово «harness»; собственный README проекта (
github.com/SWE-bench/SWE-bench, прочитан 7 сентября 2026 года) использует его пять раз, всегда как «evaluation harness», а entry point —python -m swebench.harness.run_evaluation. Это другой смысл слова: каркас, который удерживает agent на месте и оценивает его, а не цикл, который его запускает. ↩ -
Anthropic, Building effective agents, 19 December 2024,
anthropic.com/engineering/building-effective-agents, прочитано 7 сентября 2026 года. Augmented model как строительный блок, agent как LLM, «using tools based on environmental feedback in a loop», и рекомендация stopping conditions, «such as a maximum number of iterations», для сохранения контроля. Глава 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, который batching ваш запрос с запросами незнакомцев и управляет KV cache из главы 13. О его существовании стоит знать именно потому, что он не ваш: latency, которую умножает ваш harness, задаётся внутри него, и никакая работа над вашим циклом её не сдвинет. ↩
-
Счётчики скачиваний npm registry,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, явное окно вместо rollinglast-month, и истории релизов изregistry.npmjs.org/<package>; оба запрошены 7 сентября 2026 года. Счётчики релизов — число версий, опубликованных за двенадцать месяцев до этой даты, включая canary builds:ai945 (последняя 7.0.93 от 2026-09-04, major versions 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 loop, built-in file and shell tools, context management, sessions, hooks, permissions и subagents — документирован наcode.claude.com/docs/en/agent-sdk. Это ближайшее к опубликованному описанию каждого механизма, который эта глава строит вручную, и его стоит читать рядом с собственной реализацией ради частей, которые он называет, а эта глава лишь обозначает. ↩