Agent harnessを作る:ループと5つの脱出経路
初回で動く15行のループを、あえて7回壊す。暴走で厳格な上限の77倍のコストがかかるところから始めます。
このページの内容
まず正直なところから始めます。なぜなら、ほかの誰も言わないからです。「harness」は専門用語であって、標準ではありません。 仕様も委員会も参照定義もありません。この章で引用する4本の論文 — ReAct,1 CoALA,2 SWE-bench、vLLM — は、abstractの中でこの単語を一度も使っていません。このものの最もダウンロードされている実装である、月間8,940万ダウンロードのVercelのaiパッケージも同じです。バージョン7.0.93が同梱する397 KBの型宣言の中に、文字列harnessは0回しか現れません。3 この単語が本当に重い意味を持つ場所が1つだけありますが、そこではまったく別のものを意味します。SWE-benchはREADMEで「harness」を5回使っていますが、常にevaluation harnessとしてです。つまり、パッチを適用しテストを実行するコンテナ化された足場であり、そのPythonモジュールは文字どおりswebench.harness.run_evaluationです。4
つまり、名前を共有する2つの別物があります。evaluation harnessはagentを静止させ、採点します。agent harnessはagentを実行するプログラムです。モデルを呼び、モデルが求めたことを実行し、いつ止めるかを決め、その間の状態を保持します。この章では2つ目を作ります。TypeScriptで200行未満、フレームワークは一切なしです。
ループ自体は15行で、最初の試行で動きます。その後に出てくるものはすべて、そこから抜け出す方法です。
詳細を表示
この章がこれまでの章から必要とするもの。
- 第14章 クライアントについて:deadline、ステータスのトリアージ、キャンセル、冪等性キー、そしてここでも使うモックプロバイダー手法。
- 第16章 計算について:入力tokensは会話の二乗で増え、下で使う料金は2026年9月6日にそこで読んだものです。
- 第18章 ツールカタログについて:モデルが見るschema、モデルが決して見ないendpoint、そしてエラーは例外ではなくcontextであるというルール。
- 第22章 この章が引き継ぐループと、互いに食い違う「agent」の2つの公開定義。
ここにtensorはありません。これはこの講座の2つ目の依存ハブです。第24、25、29、30章は下のファイルの上で動き、第26〜28章はそれが到達できるものの上に構築されます。
スクリプト化できるプロバイダー
セクション「スクリプト化できるプロバイダー」へのリンク第14章は実在のプロバイダー相手には書けませんでした。選んだ瞬間に429を返すよう頼むことはできないからです。この章にも、別の形で同じ問題があります。実在のモデルに、暴走することや、同一ツールを同一引数で2回連続要求することを、オンデマンドかつ再現可能に頼むことはできません。
そこで最初のプログラムはスクリプト化されたプロバイダーです。chat completions APIの形をしたendpointで、返答はターン番号と、これまでツールが返した内容の関数になります。本物のbyte-pair encoderでtokensを数えるので、下の費用は飾りではなく算術です。
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);設計を担うのは2行です。ターン番号は変数に保持するのではなく、会話から導出されます。そのためプロバイダーはステートレスで、実行をkillして再開できます。そしてrecoverは、決定する前にツール結果を読みます。自分のtranscriptを読むスクリプト化モデルは、harnessが読む価値のあるものを渡したかどうかを測るために必要な最小条件です。
カタログは第18章のものです。3ファイルにまたがる4つのツール: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, 3423ターン、2回のツール実行、米セントの4分の1。最後の行に注目してください:204、269、342。各ターンはそれ以前のすべてを再送します。これは、第16章の二次関数的な請求が、誰も何も入力していない場所に届いたものです。この章の残りは、その行の増加が止まらないときに何が起きるかです。
壊し方1:終わらないタスク
セクション「壊し方1:終わらないタスク」へのリンク同じループをrunawayスクリプトに向けます。毎ターン必ずツールを求め、文章を一切出さないモデルです。すると、マークしたreturnは決して発火しません。ほかの出口はありません。プログラムはプロセスが死ぬか、クレジットカードが死ぬまで走ります。
修正は1行です。文献が最初に推奨する制御であり、5 いずれ誰もが書くものです。ほとんど誰もしないのは、それにどれだけ価値があるかを測ることです。
| turn cap | model calls | input tokens | cost |
|---|---|---|---|
| 8 | 8 | 3,431 | $0.009070 |
| 20 | 20 | 16,259 | $0.038038 |
| 50 | 50 | 88,649 | $0.191098 |
| 100 | 100 | 337,299 | $0.702198 |
最後の2行を一緒に読んでください。上限を50から100へ倍にしても、コストは倍になりません。3.7倍になりました。入力tokensは88,649から337,299へ、3.8倍になりました。ターンはそれ以前のすべてのターンを抱えており、合計がになるからです。turn capは線形のダイヤルではありません。最悪ケースの平方根に付いたダイヤルです。だから「安全のために」20から100へ上げるのは、実行する前に価格を付ける価値のある判断です。
壊し方2:turn capは費用の上限ではない
セクション「壊し方2:turn capは費用の上限ではない」へのリンクturn capの問題は、1ターンの価格が固定ではないことです。短いtranscriptでの20ターンは上で$0.038でした。200個のツールカタログ、取得されたドキュメントセット、40件の履歴メッセージを持つ20ターンは、その何百倍にもなります。そして上限はそれを知りません。オペレーターが制限したいのは請求額です。
そこでループは費用を数えます。第16章のcomputeCostを、そこで読んだ料金 — この講座を通じて価格設定の基準にしているモデルの、入力100万tokensあたり$2.00、出力100万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);同じ暴走スクリプト、turn capなし、3つの予算です。
| budget | turns reached | actually spent |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $0.205398 |
名前を付ける価値のあることが2つあります。第一に、予算ごとに買えるターン数が違います。それが要点です。オペレーターが気にするものを制限し、ターン数はtranscriptが置く場所に落とすのです。第二に、すべての行で超過しています。 予算は$0.010でしたが、$0.010780が使われました。チェックはターンの前に走り、ターンの価格は終わるまで分からないからです。支出を正確に制限することはできません。1ターン分のコスト以内に制限することはできます。インターフェースではごまかさずそう言い、チェックは呼び出しの前に置いてください。超過が2ターンではなく1ターンで済むようにするためです。
ループを出る方法は1つではなく5つ
セクション「ループを出る方法は1つではなく5つ」へのリンクここまででループには3つの出口があり、残りの章の形が見えてきます。本番の実行は、正確に5つの方法のいずれかで終わります。それらは互いの変種ではありません。
| how it ends | who decided | what the caller should do |
|---|---|---|
| モデルが要求をやめた | モデル | 答えを読む |
| turn cap | あなたが事前に | 上限を上げる、または部分結果を受け入れる |
| 予算を使い切った | あなたが事前に | 追加費用を承認する、または部分結果を受け入れる |
| retryできないエラー | プロバイダーまたはツール | deploymentを修正する。第14章のトリアージが判断する |
| 人間が介入した | 人 | 判定を待ち、その後再開する |
これらを1つのbooleanに潰すことが、このファイルで最もよくある設計ミスです。そして、それは特定の形で高くつきます。5つのうち3つは再開可能で、2つは違います。turn capに達したagentには、有効なtranscript、本物の部分結果、次のステップがあります。401を受けたagentにはそのどれもありません。だから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 };壊し方3:ツールが失敗する
セクション「壊し方3:ツールが失敗する」へのリンク第18章は、数値なしの主張で終わりました。ツールのエラーをthrowするのではなくツール結果としてモデルへ返すと、モデルはたいてい自分で直す、という主張です。ここに数値があります。
1つの失敗、3つの方針。スクリプト化されたモデルは存在しないファイルを推測し、ツールはno such file: timeout.log. Call list_files to see what exists.をthrowします。
| what the harness does with the error | turns | tool runs | cost | what the user got |
|---|---|---|---|---|
| ループの外へthrowする | 1 | 1 | $0.000756 | stack trace |
Error: the tool failed.を返す | 2 | 1 | $0.001462 | 「ファイルを読めなかったので、分かりません。」 |
| 実際に起きたことを返す | 4 | 3 | $0.003550 | 「errors.logにはtimeoutが書かれています。」 |
3行目は1行目の4.7倍のコストで、質問に答える唯一の行です。そして面白いのは2行目です。多くのcodebaseが実際にしているのはこれだからです。エラーは捕捉され、ループは生き残り、モデルには何かが失敗したことだけが伝えられ、何が失敗したかは伝えられませんでした。その結果、モデルは丁寧に諦めました。2行目と3行目の違いはエラーハンドリングではありません。読者のために書かれた1文です。
そのためharnessはthrowされたツールをデータとして扱い、文言を方針にします。
} 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 — にループを向けると、モデルは永遠にretryします。
read a file the process may not open turns=12 toolruns=11 in=7,079 cost=$0.018622
status=max_turns_exceeded answer=""成功し得ない呼び出しが11回同一に実行され、修復可能な失敗から回復した実行の5.2倍のコストがかかり、最後には何も残りません。エラーはcontextです。永続的なエラーは、その後の実行全体を汚染するcontextです。 この区別は、第14章のステータストリアージを1層上に移したものです。モデルが対処できるエラーはtranscriptに戻し、対処できないエラーは理由付きで実行を止めるべきです。今日あなたと2つ目のケースの間に立っているのはturn capであり、それは最低限の床であって修正ではありません。
壊し方4:同じ呼び出しが2回
セクション「壊し方4:同じ呼び出しが2回」へのリンク次は、多くの人が起きないと思い込んでいる失敗です。モデルは同じことを繰り返します。どんなループでも十分長く走らせれば、同一ツールが同一引数で2ターン連続に出てくるのを見ます。
同じタスクを重複なしで実行したbaselineと比べます。
| turns | tool runs | cost | |
|---|---|---|---|
| タスク、重複なし | 2 | 1 | $0.001396 |
| 同じタスク、1回の呼び出しが重複 | 3 | 2 | $0.002446 |
| 重複あり、read-onlyツールにresult cache | 3 | 1 | $0.002446 |
重複した呼び出しは追加で$0.001050、75 %増のコストでした。そして人々を驚かせるのはここです。結果をcacheしても、回収できたのはゼロでした。deduplicationはツール実行を節約しましたが、ターンは節約しませんでした。あなたのコードが重複に気づくころには、モデルにはすでに要求分の料金が支払われているからです。ツールが遅い、rate-limitされる、呼び出しごとに課金される場合には節約は本物です。しかし増えた明細行ではゼロです。
もっと悪いバージョンがあります。同じcacheを書き込みツールに適用すると、2回目の呼び出しは黙って起きません。
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上、これは2つの呼び出しです。2つの異なるtool_call_id値を持っています。引数上は1つかもしれません。引数文字列の比較で判断するharnessは、いつか意図された同一の2回の請求のうち2回目を飲み込みます。そして第14章は、これを正直に解決する唯一の仕組みをすでに名付けました。それは、その操作が何であるかを知る層が論理操作ごとに生成する冪等性キーです。ツールがそれを持つまでは、防御可能なデフォルトは上のread-only gateです。readはcacheし、writeは実行し、残りは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;
}壊し方5:何かを削除する
セクション「壊し方5:何かを削除する」へのリンク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."これが仕組みのすべてです。そして、それがcallbackではなくreturnである理由は次のセクションにあります。停止から判定までの間に、プロセスはもう存在しないかもしれないからです。
しかしその前に、誰も予想しない測定です。拒否は結果の不在ではありません。transcriptにはtool_call_idをkeyにしたslotがあり、そこには何かを入れなければなりません。同じ拒否を2回実行し、その何かが何を言うかだけを変えます。
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."どちらの実行でも何も削除されていません。そして2回目では、ユーザーは削除されたと告げられます。 権限システムは完璧に動きました。レポートが嘘なのです。これはツールエラー表と同じ仕組みが、はるかに重要な場所に到着したものです。人間がnoと言い、actionは正しくブロックされました。しかし拒否がモデルの読む場所に書き込まれなかったため、agentの要約は現実と矛盾します。ここから出てくるルールは短いです。あなたのコードがツール呼び出しについて何を決めたとしても、その判断を言葉でtranscriptに書き込んでください。 第30章はこれをセキュリティ側から扱います。そこでは、audit trailと作り話の違いになります。
壊し方6:プロセスが死ぬ
セクション「壊し方6:プロセスが死ぬ」へのリンク承認には数分から数時間かかります。deployには数秒かかります。実行がHTTP requestの中のローカル変数に生きているなら、再起動のたびに実行は失われ、承認のたびにraceになります。
だから実行はclosureではありません。plain serialisable objectです。messages、turn count、cost、status、interruption、承認済みcall idsのリストを持ち、ループはそれに対するpure functionです。この単一の制約により、永続化は1行の関心事になります。
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は、すでに持っているターンにもう一度料金を払います。ツールの再実行から始めると、writeを2回実行します。
修正は、ループの最初にtranscriptへ未処理のものを尋ねることです。
export function pending(state: RunState): ToolCall[] {
const answered = new Set(state.messages.filter((m) => m.role === "tool").map((m) => m.tool_call_id));
const last = state.messages.at(-1);
if (last?.role !== "assistant") return [];
return (last.tool_calls ?? []).filter((c) => !answered.has(c.id));
}各iterationはまずpendingをdrainし、未処理が何もないときだけモデルに尋ねます。resumeは通常のcode pathと同じになり、approvalも同じです。承認済みcallとは、単に今は実行が許されているpending callです。タスクの途中でプロセスをkillし、再起動します。
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.log2回のツール実行が2つのプロセスにまたがり、必要なツール実行数が2回のタスクに対して2回で済みます。最終コストは、クラッシュしなかった実行と同一です。コストが再起動をまたいで累積するのは、それが変数ではなくstateの中にあったからです。
壊し方7:3分間の沈黙
セクション「壊し方7:3分間の沈黙」へのリンクscan_archiveはここでは3秒かかり、本番で3分かかるツールの代理です。実行中には2つのものが欠けています。ユーザーには何かが起きていることが分からず、Stopボタンは何もしません。
どちらも同じ修正であり、第14章のAbortSignalを1段深く押し込んだものです。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"クリックから停止まで2ミリ秒です。ツール内のsleepがfetchと同じsignalをlistenしているからです。それをfetchにしか通さない場合、同じStopボタンは3秒 — ツールの長さ — 待ち、キャンセルしようとしていた作業がすでに終わった後で実行が「キャンセル」されます。末端まで配管されていないキャンセルは、正しい言葉を表示するspinnerでしかありません。
trace、そしてそれがlogではない理由
セクション「trace、そしてそれがlogではない理由」へのリンクharnessはeventごとに1行をemitします。語彙は覚えられるほど小さいです: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}これをloggingではなくtraceにする性質が3つあります。すべての行がrun idを持つため、3つのプロセスと2日間にまたがる実行が1つのqueryになります。すべてのturn行が自身のtoken countと累積costを持つため、「なぜこの実行は40ドルかかったのか」に、理論上だけ再現可能なのではなく、事後に答えられます。そしてrun_stoppedがreasonを持ちます。これはsupport ticketを1行の答えに変えるfieldです。予算で止まったagentとクラッシュしたagentは外から見ると同じで、必要な対応は正反対です。
latencyの算術
セクション「latencyの算術」へのリンク第13章は自分が所有するhardware上でのtime to first tokenを測りました。第14章はsocket越しにそれを測りました。agentはそれを掛け算します。そして乗数は誰も選んでいない数です。
同じ3ターンタスクで、プロバイダーのlatencyだけを変えます。
| provider latency per turn | wall clock, 3 turns |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2,413 ms |
harness自体が3ターン実行に寄与するのは15ミリ秒です。それ以外はすべて、あなたが制御しない数を掛けられたです。その数は、見知らぬ人のrequestとあなたのrequestをbatchしているserving schedulerの中で設定されます6。そしてはモデルが選びます。これが、第14章のstreamingがchatよりここで重要になり、しかし助けは小さくなる理由です。最終ターンはstreamできますが、その前の4ターンは、harnessがprogressをemitしない限り沈黙です。これは上のtool_progresseventの議論そのものでもあります。agentにおけるfeedbackの正直な単位はtokenではなくstepです。
同じharness、ポートの向こうに本物のモデル
セクション「同じharness、ポートの向こうに本物のモデル」へのリンクここまでのすべてはスクリプト化プロバイダー相手に走りました。それはharnessを証明しますが、モデルについては何も証明しません。そこで1行だけ変えます。第14章のseam、LLM_BASE_URLです。同一コードを、同じ4つのツールを持つローカルのQwen2.5-0.5B-Instructに向けます。同じ3ファイルに対する6つのタスクです。
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発見は3つあり、3つ目がこのセクションの存在理由です。
すべてのタスクが正確に2ターンで終わりました。 turn capは一度も発火せず、budgetも一度も発火せず、ループ唯一の出口はモデルが文章を生成することでした。5億parameterのモデルはiterateしません。必要なものを持っているかどうかにかかわらず、2回目の呼吸で答えます。ターン数はループの性質ではなく、モデルの性質です。
平均ターンは6,908ミリ秒でした。つまり上のlatency表は玩具ではありません。このサイズでは、仮に8ターンの実行があれば、画面に何も出ないまま壁時計でほぼ1分です。
そして答えは間違っています。 最大のファイルはerrors.logです。モデルはファイルを一覧し、それらを読まず、それでも1つを名指ししました。最初のタスクはファイル名を推測し、存在しないと伝えられ、そこで結論しました。harnessは6回すべてで完璧に実行されました。harnessはagentをgovernableにしますが、正しくはしません。第29章はどちらなのかを調べる方法であり、第30章は誰もそれをしなかったときのコストです。
Subagents、ここで名付け、後で課金する
セクション「Subagents、ここで名付け、後で課金する」へのリンクカタログ内の1つのツールは、その背後に別の実行を持てます。interfaceは第18章のもの — schemaとendpoint — であり、そのinterfaceが狭いため、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";
},
};この10行の中で、すでに正しいことが3つあります。そして3つとも上で下した判断の帰結です。childは自分専用のwindowを持つため、parentのtranscriptはchildが読んだすべてではなく要約を受け取ります。childは自分専用のlimitを持つため、暴走するchildがparentのbudgetを使い切ることはできません。そしてchildはsignalを継承するため、1つのStopがtreeをキャンセルします。きれいなwindowが副作用ではなく要点である理由は第24章です。5つのorchestration pattern — prompt chaining、routing、parallelisation、orchestrator-workers、evaluator-optimiser — とhandoffは第25章です。
フレームワークはどこにあり、なぜこの講座では使わなかったのか
セクション「フレームワークはどこにあり、なぜこの講座では使わなかったのか」へのリンク上のどれも、libraryへの反論として読むべきではありません。2026年9月7日に、8月29日終了月について測定すると、次のとおりです。7
| package | downloads that month | what it gives you |
|---|---|---|
ai (Vercel AI SDK) | 89,385,860 | ToolLoopAgent、stopWhen、tool approval、step hooks |
@anthropic-ai/claude-agent-sdk | 41,558,352 | libraryとしてのClaude Code harness:loop、sessions、hooks、permissions、subagents8 |
@langchain/langgraph | 12,812,815 | 明示的なstate graphとしてのloop |
langchain | 11,359,058 | chains、agents、integrations |
@openai/agents | 6,093,155 | agents、handoffs、guardrails |
@mastra/core | 5,914,502 | agents、workflows、memory |
この講座がそれらの1つを教える代わりにloopを手で書く理由は、暗示ではなく明言します。そしてそれは測定可能です。2026年9月7日までの12か月で、aiは945バージョンを公開し、major 5からmajor 7へ移動しました。それでもagent classはExperimental_Agentとしてexportされています。langchainは同じ期間に132バージョンを公開しました。@openai/agentsは83を公開し、初回リリースから15か月後でもまだ0.xです。7 これらのAPIのどれかに対して書かれた章は、1シーズンで古くなります。そしてこの講座は33言語で公開されるため、再編集のたびに翻訳全体にコストがかかります。それらすべての下にあるものは動きません。loop、stopping rule、catalogue、executor、stateです。
そしてreference implementationは、重要な部分についてこの章と一致しています。aiバージョン7.0.93では、loopのexitは数値ではありません。stopWhen、つまりpredicateのリストであり、step countはその1つにすぎません。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このloopの最も使われている実装で停止が複数形なのは、上の196行で複数形であるのと同じ理由です。
次に進む場所
セクション「次に進む場所」へのリンクあなたにはもうharnessがあります。loop、catalogue、executor、5つの出口、永続化されたrun、toolsに届くsignal、そしてすべての行にrun idを持つtraceです。第24、25、29、30章はこのファイルの上に構築され、第26〜28章はそれが到達できるものの上に構築されます。
残っている問題は1つです。そして上の測定は最初からずっとそれを指していました。暴走表をもう一度見てください。8ターンで3,431 input tokens、100ターンで337,299。動く実行を見てください。204、269、342。各ターンはtranscript全体を再送するため、agentのcontextは自分自身の履歴で埋まっていきます。そしてモデルは、長いwindowの遠い端を近い端ほど上手く使えません。だから5ターン目では良いagentが、40ターン目には混乱したagentになります。
turn capはそれを修正しません。それが起きるのを見るために支払い続けるのを止めるだけです。修正するのは、毎ターンごとに、どのtokensがwindowに値するかを決めることです。何をcompactするか、何をagentがfetchできるnoteへ外出しするか、何をきれいなwindowを持つsubagentへ渡すか、どのツール定義が恒久的なtaxに値するか。第24章はwindowが実際にどこへ行くかを測ります。そして驚くべきことに、それは会話ではありません。
Sources and method
セクション「Sources and method」へのリンクこの章のすべての数値は、上で説明した2つのserverから出たものです。Node 22、loopback interface上で、o200k_base encodingでtokensを数えるスクリプト化プロバイダーと、同じshapeのendpointの背後にあるQwen/Qwen2.5-0.5B-Instruct、CPU上のgreedy decodingです。コストは、第16章が2026年9月6日に読んだ料金 — 入力100万tokensあたり$2.00、出力100万tokensあたり$12.00 — で、測定token countから計算しています。この章のrequestはどれも有料endpointへ送っていません。ローカルモデルの答えは小さなモデルの答えです。どちらの場合も同一であるloopについての証拠として読み、現在のモデルが何をするかの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)。loopが実装するreasoning traceとactionのinterleavingであり、actingによってモデルが「handle exceptions」できるという観察の出典です。これはまさに上のツールエラー表が測ったものです。 ↩
-
Sumers, T. R., Yao, S., Narasimhan, K. and Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023)。上のloopがinformalに行うことのformalな扱い:modular memory components、internal memoryとexternal environmentsにまたがるstructured action space、そして「actionsを選ぶgeneralized decision-making process」。業界用語に欠けている語彙のために読むべきです。特にworking、episodic、semantic、procedural memoryの分離であり、その実務上の影は第24章の3-store tableです。 ↩
-
ai(Vercel AI SDK)バージョン7.0.93、2026年9月4日公開。型宣言は2026年9月7日にcdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.tsから読んだ。397 KBのファイルには文字列harnessが0回含まれる。agent classはdeclare class ToolLoopAgentで、ToolLoopAgentとしてもExperimental_Agentとしてもexportされる。declare function isStepCount(stepCount: number)—stepCountIsとしてexport — は上で逐語引用した。type StopConditionは第2のtype parameter(RUNTIME_CONTEXT extends Context = Context)なしで示しており、これは抜粋内で唯一の省略である。generateTextとstreamText上のstopWhen?: Arrayable<StopCondition<...>>のshapeについても同様。同じファイルはtoolApproval、ToolApprovalStatus、prepareStep、repairToolCallを宣言している。つまりreference implementationは、approval gate、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)。abstractは成果物を2,294問の「evaluation framework」と呼び、「harness」という語を一度も使わない。project自身のREADME(
github.com/SWE-bench/SWE-bench、2026年9月7日に閲覧)は5回使い、常に「evaluation harness」としてであり、entry pointはpython -m swebench.harness.run_evaluationである。それがこの語のもう1つの意味、つまりagentを静止させ採点する足場であって、agentを実行するloopではない。 ↩ -
Anthropic, Building effective agents, 2024年12月19日、
anthropic.com/engineering/building-effective-agents、2026年9月7日に閲覧。building blockとしてのaugmented model、environmental feedbackに基づいてloop内でtoolsを使うLLMとしてのagent、そして制御を保つための「maximum number of iterations」などのstopping conditionsの推奨。第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)。もう1つのloop、つまりあなたのrequestを見知らぬ人のrequestとbatchし、第13章のKV cacheを管理するserving schedulerです。それが存在することを知る価値があるのは、まさにそれがあなたのものではないからです。harnessが掛け算するlatencyはその中で決まり、あなたのloopにどれだけ手を入れても動きません。 ↩
-
npm registryのdownload count、
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>。rollingなlast-monthではなく明示的なwindowであり、release historyはregistry.npmjs.org/<package>から。どちらも2026年9月7日にqueryした。release countはその日までの12か月に公開されたversion数で、canary buildを含む:ai945(latest 7.0.93、2026-09-04。major version 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としてpackageしたものです — agent loop、built-in file and shell tools、context management、sessions、hooks、permissions、subagents —code.claude.com/docs/en/agent-sdkでdocumentされています。この章が手で作る各仕組みについて公開された説明として最も近いものであり、この章が示唆するだけの部分にそれが付けている名前を、自分の実装の横で読む価値があります。 ↩