コンテンツへスキップ
23/30第23章 / 全30章

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を数えるので、下の費用は飾りではなく算術です。

mock-provider.mjsJS
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_filesread_filedelete_fileneedsApprovalとしてマーク — そして意図的に遅いscan_archiveです。

生き残れるようにする部品を入れる前に、全体の発想を示します。

loop.tsTS
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 });
  }
}

これをスクリプト化プロバイダーに向けると、見たとおりのことをします。

TEXT
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

3ターン、2回のツール実行、米セントの4分の1。最後の行に注目してください:204、269、342。各ターンはそれ以前のすべてを再送します。これは、第16章の二次関数的な請求が、誰も何も入力していない場所に届いたものです。この章の残りは、その行の増加が止まらないときに何が起きるかです。

同じループをrunawayスクリプトに向けます。毎ターン必ずツールを求め、文章を一切出さないモデルです。すると、マークしたreturnは決して発火しません。ほかの出口はありません。プログラムはプロセスが死ぬか、クレジットカードが死ぬまで走ります。

修正は1行です。文献が最初に推奨する制御であり、5 いずれ誰もが書くものです。ほとんど誰もしないのは、それにどれだけ価値があるかを測ることです。

turn capmodel callsinput tokenscost
883,431$0.009070
202016,259$0.038038
505088,649$0.191098
100100337,299$0.702198

最後の2行を一緒に読んでください。上限を50から100へ倍にしても、コストは倍になりません。3.7倍になりました。入力tokensは88,649から337,299へ、3.8倍になりました。ターンnnはそれ以前のすべてのターンを抱えており、合計がΘ(n2)\Theta(n^2)になるからです。turn capは線形のダイヤルではありません。最悪ケースの平方根に付いたダイヤルです。だから「安全のために」20から100へ上げるのは、実行する前に価格を付ける価値のある判断です。

turn capの問題は、1ターンの価格が固定ではないことです。短いtranscriptでの20ターンは上で$0.038でした。200個のツールカタログ、取得されたドキュメントセット、40件の履歴メッセージを持つ20ターンは、その何百倍にもなります。そして上限はそれを知りません。オペレーターが制限したいのは請求額です。

そこでループは費用を数えます。第16章のcomputeCostを、そこで読んだ料金 — この講座を通じて価格設定の基準にしているモデルの、入力100万tokensあたり$2.00、出力100万tokensあたり$12.00 — に対して使います。

harness.tsTS
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つの予算です。

budgetturns reachedactually spent
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

名前を付ける価値のあることが2つあります。第一に、予算ごとに買えるターン数が違います。それが要点です。オペレーターが気にするものを制限し、ターン数はtranscriptが置く場所に落とすのです。第二に、すべての行で超過しています。 予算は$0.010でしたが、$0.010780が使われました。チェックはターンの前に走り、ターンの価格は終わるまで分からないからです。支出を正確に制限することはできません。1ターン分のコスト以内に制限することはできます。インターフェースではごまかさずそう言い、チェックは呼び出しの前に置いてください。超過が2ターンではなく1ターンで済むようにするためです。

ここまででループには3つの出口があり、残りの章の形が見えてきます。本番の実行は、正確に5つの方法のいずれかで終わります。それらは互いの変種ではありません。

how it endswho decidedwhat the caller should do
モデルが要求をやめたモデル答えを読む
turn capあなたが事前に上限を上げる、または部分結果を受け入れる
予算を使い切ったあなたが事前に追加費用を承認する、または部分結果を受け入れる
retryできないエラープロバイダーまたはツールdeploymentを修正する。第14章のトリアージが判断する
人間が介入した判定を待ち、その後再開する

これらを1つのbooleanに潰すことが、このファイルで最もよくある設計ミスです。そして、それは特定の形で高くつきます。5つのうち3つは再開可能で、2つは違います。turn capに達したagentには、有効なtranscript、本物の部分結果、次のステップがあります。401を受けたagentにはそのどれもありません。だからharnessは理由をデータとして記録します。

harness.tsTS
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章は、数値なしの主張で終わりました。ツールのエラーをthrowするのではなくツール結果としてモデルへ返すと、モデルはたいてい自分で直す、という主張です。ここに数値があります。

1つの失敗、3つの方針。スクリプト化されたモデルは存在しないファイルを推測し、ツールはno such file: timeout.log. Call list_files to see what exists.をthrowします。

what the harness does with the errorturnstool runscostwhat the user got
ループの外へthrowする11$0.000756stack trace
Error: the tool failed.を返す21$0.001462「ファイルを読めなかったので、分かりません。」
実際に起きたことを返す43$0.003550「errors.logにはtimeoutが書かれています。」

3行目は1行目の4.7倍のコストで、質問に答える唯一の行です。そして面白いのは2行目です。多くのcodebaseが実際にしているのはこれだからです。エラーは捕捉され、ループは生き残り、モデルには何かが失敗したことだけが伝えられ、何が失敗したかは伝えられませんでした。その結果、モデルは丁寧に諦めました。2行目と3行目の違いはエラーハンドリングではありません。読者のために書かれた1文です。

そのためharnessはthrowされたツールをデータとして扱い、文言を方針にします。

harness.tsTS
} 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します。

TEXT
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であり、それは最低限の床であって修正ではありません。

次は、多くの人が起きないと思い込んでいる失敗です。モデルは同じことを繰り返します。どんなループでも十分長く走らせれば、同一ツールが同一引数で2ターン連続に出てくるのを見ます。

同じタスクを重複なしで実行したbaselineと比べます。

turnstool runscost
タスク、重複なし21$0.001396
同じタスク、1回の呼び出しが重複32$0.002446
重複あり、read-onlyツールにresult cache31$0.002446

重複した呼び出しは追加で$0.001050、75 %増のコストでした。そして人々を驚かせるのはここです。結果をcacheしても、回収できたのはゼロでした。deduplicationはツール実行を節約しましたが、ターンは節約しませんでした。あなたのコードが重複に気づくころには、モデルにはすでに要求分の料金が支払われているからです。ツールが遅い、rate-limitされる、呼び出しごとに課金される場合には節約は本物です。しかし増えた明細行ではゼロです。

もっと悪いバージョンがあります。同じcacheを書き込みツールに適用すると、2回目の呼び出しは黙って起きません。

TEXT
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自身の冪等性に任せます。

harness.tsTS
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とマークされたツールは失敗せず、続行もしません。実行を止めて制御を返します。判断に必要なものをすべて人間に渡します。

harness.tsTS
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) });
}
TEXT
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回実行し、その何かが何を言うかだけを変えます。

TEXT
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と作り話の違いになります。

承認には数分から数時間かかります。deployには数秒かかります。実行がHTTP requestの中のローカル変数に生きているなら、再起動のたびに実行は失われ、承認のたびにraceになります。

だから実行はclosureではありません。plain serialisable objectです。messages、turn count、cost、status、interruption、承認済みcall idsのリストを持ち、ループはそれに対するpure functionです。この単一の制約により、永続化は1行の関心事になります。

harness.tsTS
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へ未処理のものを尋ねることです。

harness.tsTS
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し、再起動します。

TEXT
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

2回のツール実行が2つのプロセスにまたがり、必要なツール実行数が2回のタスクに対して2回で済みます。最終コストは、クラッシュしなかった実行と同一です。コストが再起動をまたいで累積するのは、それが変数ではなくstateの中にあったからです。

scan_archiveはここでは3秒かかり、本番で3分かかるツールの代理です。実行中には2つのものが欠けています。ユーザーには何かが起きていることが分からず、Stopボタンは何もしません。

どちらも同じ修正であり、第14章のAbortSignalを1段深く押し込んだものです。signalはfetchだけのものではありません。ツールの中へ渡され、よく書かれたツールはそれを尊重します。

harness.tsTS
result = await tool.run(JSON.parse(c.function.arguments), {
  signal,                                                                     
  progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});
TEXT
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でしかありません。

harnessはeventごとに1行をemitします。語彙は覚えられるほど小さいです:turntool_starttool_progresstool_resultapproval_requiredrun_stopped

TEXT
{"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_stoppedreasonを持ちます。これはsupport ticketを1行の答えに変えるfieldです。予算で止まったagentとクラッシュしたagentは外から見ると同じで、必要な対応は正反対です。

第13章は自分が所有するhardware上でのtime to first tokenを測りました。第14章はsocket越しにそれを測りました。agentはそれを掛け算します。そして乗数は誰も選んでいない数です。

TrunN(tmodel+ttools)T_{\text{run}} \approx N \cdot \left( t_{\text{model}} + t_{\text{tools}} \right)

同じ3ターンタスクで、プロバイダーのlatencyだけを変えます。

provider latency per turnwall clock, 3 turns
0 ms15 ms
200 ms615 ms
800 ms2,413 ms

harness自体が3ターン実行に寄与するのは15ミリ秒です。それ以外はすべて、あなたが制御しない数を掛けられたNNです。その数は、見知らぬ人のrequestとあなたのrequestをbatchしているserving schedulerの中で設定されます6。そしてNNはモデルが選びます。これが、第14章のstreamingがchatよりここで重要になり、しかし助けは小さくなる理由です。最終ターンはstreamできますが、その前の4ターンは、harnessがprogressをemitしない限り沈黙です。これは上のtool_progresseventの議論そのものでもあります。agentにおけるfeedbackの正直な単位はtokenではなくstepです。

ここまでのすべてはスクリプト化プロバイダー相手に走りました。それはharnessを証明しますが、モデルについては何も証明しません。そこで1行だけ変えます。第14章のseam、LLM_BASE_URLです。同一コードを、同じ4つのツールを持つローカルのQwen2.5-0.5B-Instructに向けます。同じ3ファイルに対する6つのタスクです。

TEXT
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章は誰もそれをしなかったときのコストです。

カタログ内の1つのツールは、その背後に別の実行を持てます。interfaceは第18章のもの — schemaとendpoint — であり、そのinterfaceが狭いため、agent全体がその背後に収まります。

subagent.tsTS
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

packagedownloads that monthwhat it gives you
ai (Vercel AI SDK)89,385,860ToolLoopAgentstopWhen、tool approval、step hooks
@anthropic-ai/claude-agent-sdk41,558,352libraryとしてのClaude Code harness:loop、sessions、hooks、permissions、subagents8
@langchain/langgraph12,812,815明示的なstate graphとしてのloop
langchain11,359,058chains、agents、integrations
@openai/agents6,093,155agents、handoffs、guardrails
@mastra/core5,914,502agents、workflows、memory

この講座がそれらの1つを教える代わりにloopを手で書く理由は、暗示ではなく明言します。そしてそれは測定可能です。2026年9月7日までの12か月で、ai945バージョンを公開し、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

ai-sdk.tsTS
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が実際にどこへ行くかを測ります。そして驚くべきことに、それは会話ではありません。


この章のすべての数値は、上で説明した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としては読まないでください。

  1. 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」できるという観察の出典です。これはまさに上のツールエラー表が測ったものです。

  2. 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です。

  3. 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)なしで示しており、これは抜粋内で唯一の省略である。generateTextstreamText上のstopWhen?: Arrayable<StopCondition<...>>のshapeについても同様。同じファイルはtoolApprovalToolApprovalStatusprepareSteprepairToolCallを宣言している。つまりreference implementationは、approval gate、per-step preparation、error repairに独自に到達している。 2

  4. 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ではない。

  5. 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章はその定義を全文引用しています。

  6. 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にどれだけ手を入れても動きません。

  7. 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を含む:ai 945(latest 7.0.93、2026-09-04。major version 5、6、7がすべてwindow内に出現)、langchain 132(latest 1.5.10、2026-08-20)、@openai/agents 83(latest 0.17.0、2026-08-19。first published 2025-06-03)。 2

  8. 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されています。この章が手で作る各仕組みについて公開された説明として最も近いものであり、この章が示唆するだけの部分にそれが付けている名前を、自分の実装の横で読む価値があります。


作成者

David Vicente Campos

NeuraLIA Labs創業者、MyRealFood共同創業者

レオン大学出身のコンピューターエンジニアです。MyRealFoodを共同創業し、CTOとして、何百万人もの人がより良い食生活のために使ってきたアプリを開発しました。また、NeuraLIA Labsを創業し、そこでAIプロダクトを開発しています。ここでは、私がその過程で理解する必要があったことを、誰かにこう説明してほしかったと思う形で書いています。

著者について詳しく

NeuraLIA Labsが公開しています。

新着記事を受信トレイにお届け

AIニュース、ガイド、プロダクトアップデートを、読む価値のある記事を公開したときだけ短いメールでお送りします。

コース目次

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev読了15分

Jev AIモデルは文章ではなく意思決定のために作られている

TypeSafe AIのJevが注目されているのは、ソフトウェアの知能を確率の問題として扱うからです。適切な分岐を選び、信頼度を添え、コードが必要としているのが意思決定であるときに、LLMに文章を書かせるためのコストを避けます。

Abstract legal research workspace with documents, search nodes and governance controls.
openai読了14分

OpenAIのAstra for Lawは新モデルではなく、法律AIシステム

OpenAIの法律分野での発表の本質は、新しい基盤モデルそのものではなく、その周辺にあるシステムです。ドメイン検索、信頼できるツール、権限、ベンチマーク、レビュー経路が重要になります。

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering読了12分

Context engineering for long-horizon AI agents

Long-running agents do not fail only because the window is small. They fail when files, tool outputs and stale history crowd out the task the agent was supposed to finish.

モデル選びは、LIAにおまかせ。

すべてのAIモデルをひとつの場所で。今日から無料で。