第 43 章:スタックループ検出
「繰り返し呼び出しを検出する仕組みを足せばいい」——ところが記録上最悪のスタックループでは、繰り返された呼び出しが毎回「成功」を返していた。
5 分で要点掴む
- 検出器は証拠の集約であって単一ルールではない——公開実装には 9 つの検出経路がある
- 判定は 3 種類:
Continue、Nudge、ForceStop。エスカレートしない Nudge は飾り- エラーには成功より広い予算を与える——失敗のリトライは正当な作業
- 検出が縛れるのは繰り返しであって、最初の破壊的な呼び出しは防げない
- その修正はツール契約の側にあり、検出器の側にはない
10 分パス:43.1-43.2 → 43.5 → Kocoro OSS Lab
公開ソースのスナップショット:実装例は 2026-07-27 に確認した Kocoro 公開
origin/mainのコミット4ec6772に基づきます。学ぶべき対象は不変条件であり、定数は最新ソースで再確認してください。
43.1 毎回「成功」を返した書き込みの話
2026-05-13、本番環境。Agent が file_write を呼びます。path はあるが content フィールドがありません。
Go の json.Unmarshal は、強く型付けされた構造体において「フィールドが無い」と「フィールドはあるがゼロ値」を区別できません。だから content は "" として届きます。そして os.WriteFile は空文字列を書くことをまったく厭いません。ユーザーの既存ファイルを 0 バイトに切り詰め、エラーは返しません。
ツールは IsError: false を返します。
モデルはその結果を読み、成功だと理解し、そして——たった今壊したファイルには依然として欲しい内容が無いので——もう一度試します。そしてまた。
では、ループ検出器は何をしたのでしょうか。
発火しました。このパターンを再現する回帰テストには、修正前は重複検出が4 回目の同一呼び出しで作動したと記録されています。検出器は盲目でもなく、壊れてもいませんでした。
ただ、手遅れだっただけです。ファイルは 1 回目の呼び出しですでに失われていました。
これが居心地の悪い教訓であり、「これ用の検出器を足す」よりずっと狭い話です。ループ検出が縛れるのは繰り返し——間違ったことを何回やるか——であって、最初の間違いは縛れません。ファイルを切り詰めながら成功を報告するツールは、どの検出器が比較用のデータ点を 2 つ揃えるより前に、すでに損害を与え終えています。
当時どのシグナルが使えなかったかにも注目してください。無進展検出?すべての呼び出しが進展を報告しています。同一ツールのエラー検出?エラーは 1 つもありません。手札があったのは重複の経路だけで、しかも重複には意図的に余地が与えられています。繰り返しの呼び出しは、Agent がリストを順に処理していく正常な形そのものだからです。
43.2 9 つの経路、シグナル 1 つでは足りないから
公開実装には 9 つの検出経路があり、順に評価して最初に一致したものが勝ちます。別々の検出器として存在するのは、それぞれが本当に異なる形の「詰まり」を捕まえるからです。
EmptyThink は連続 2 回の think({}) で発火します。ネイティブのインターリーブ推論の後に出る儀式的な空思考です。ToolModeSwitch と SuccessAfterError は、すでに成功したことに対する冗長な視覚確認を捕まえます。ConsecutiveDuplicate は連続した同一呼び出しを、ExactDuplicate は同じ呼び出しがウィンドウ内に散らばって再出現する形——読む→編集→読む→編集→読む——を捕まえます。SameToolError は 1 つのツールが同じ形で失敗し続けるのを、FamilyNoProgress は関連ツールが 1 つの話題の周りを回るのを、SearchEscalation は末尾で有用な成果を生まない検索の連続を捕まえます。NoProgress は汎用のバックストップで、引数を問わず同一ツールの回数を見ます。
9 つであって 1 つでない理由。単一の「繰り返し呼び出し」ルールは、正当な反復で誤発火するか、微妙なケースを取り逃すかのどちらかになります。異なるクエリで 4 回検索するのはループではありません。4 回検索して何も引用できないのがループです。10 個のファイルを読むのはループではありません。編集が失敗するたびに同じファイルを読み直すのがループです。
各検出器は「無進展」の異なる定義を符号化しています。 だからこの構成は第一原理からではなく、インシデントから育ちます。
43.3 3 つの判定と、エラーに余地が広い理由
検出器は Continue、Nudge、ForceStop のいずれかを返します。
Nudge はメッセージを注入して「繰り返しているようだ」と伝え、方針を変える機会を与えます。ForceStop はメッセージを注入したうえで、ツールなしの最終モデル呼び出しを 1 回行い、どこまで進んだかをモデルに要約させてからループを抜けます。この最後の細部が重要です。要約なしで止めると、ユーザーの手元には途中で切れた実行と、説明ゼロだけが残ります。まともな停止とは、自分で説明する停止です。
ここからが面白い非対称です。コンストラクタの既定値は連続重複が 3、分散重複が 5。それぞれ 2 と 3 から引き上げられました。厳しすぎる値が正当なリファクタや再検索のループで誤発火したためです。ところがその実行のすべての呼び出しがエラーである場合、予算は倍になります。
なぜ失敗に寛容なのか。失敗する操作をリトライするのは正当な作業だからです。ネットワーク呼び出しが 2 回失敗して 3 回目に成功するのは、システムが正しく動いている姿です。一方、同じ引数で同じ呼び出しが 2 回成功するのは、無駄か嘘のどちらかです。繰り返される失敗はしばしば進展であり、繰り返される成功はめったに進展ではありません。
逆方向の対になる規則もあります。直近の呼び出しが成功していて、その実行の以前にエラーがあったなら、重複検出器はスキップします。モデルはたった今復帰したところで、抜け出せた瞬間に罰するのは端的に誤りです。
Nudge 自身にも境界が要ります。上限のない Nudge は飾りです。 モデルはループしていると告げられ、無視し、また告げられる。ローリングウィンドウ内でのエスカレーションがあって初めて、Nudge は実際の制御になります。
43.4 43.1 の修正は検出器側にない
あのゼロバイト書き込みに戻ります。
閾値のチューニングで逃げることもできます。重複の閾値を下げる、あるいは引数が同一の成功呼び出しを特例扱いする。しかし重複検出器はすでに 4 回目で発火しています。3 に、2 に締めても、縮むのは尾だけです。1 回目の呼び出しはやはりファイルを切り詰めており、最初のデータ点が存在する前まで届く閾値はありません。締めれば別の場所で代償も払います。その閾値が 3 と 5 に引き上げられたのは、厳しすぎる値が正当なリファクタのループで誤発火したからです。
本当の修正は上流にあります。失敗を可読にすること。 各ツールの Run は、アンマーシャル直後に Required リストの各フィールドが非ゼロであることを明示的に検査し、手作りのエラー結果ではなく型付きの検証エラーを返します。
そしてそのエラーのプレフィックスが耐荷重部材です。[validation error] というマーカーがあることで、専用の検査が「同一ツール + 同一引数 + 連続 3 回の検証エラー」を直接 ForceStop へ短絡できます。汎用の全エラー予算(7 回目まで発動しない)よりずっと手前です。プレフィックスなしの手作り IsError: true を返せば、この早期停止を失い、遅い汎用経路に落ちます。
この一般原則は単独で述べる価値があります。検出器はツール層が報告したものしか見えません。 「何もせずに成功を返すツール」も最終的には捕まりますが、それは既に手を下した後です。検出は第 2 の防衛線です。第 1 は真実を語るツールです。
43.5 検出器は削除もされる
かつて Sleep 検出器がありました。bash コマンドに sleep N が含まれればスタックループのシグナルとみなす——sleep 5 && retry を書くモデルは、たいてい永遠に変わらない外部状態を待っているから、という理屈です。
2026 年 5 月に削除されました。理由はこうです。
produced false positives on legitimate parallel-sleep tasks. Real polling is now covered by ExactDup (same command repeated), NoProgress (many bash calls without progress), and the idle watchdog (silent long-running commands).
ここから 2 つ持ち帰る価値があります。第 1 に、この削除の正当性は、その失敗モードがすでに他の 3 つのシグナルで覆われていることを示した点にあります。「もう重要でないと判断した」ではありません。第 2 に、誤発火が命中を上回る検出器は無いより悪い。その仕組み自体を無視するよう人を訓練してしまうからです。
第 32 章の検出器表が明示的に歴史的と注記されているのはこのためです。あれはより古い構成の記録であり、構成はインシデントの蓄積とともに変わります。検出器の数はアーキテクチャの不変条件ではありません。 対応するインシデントを誰も言えない検出器が自分のシステムにあるなら、それは削除の候補です。
43.6 スナップショットの根拠
| 観測 | 4ec6772 のソース位置 |
|---|---|
| 9 つの検出経路、最初の一致が勝つ | loopdetect.go L39 |
| 連続重複の既定 3(2 から引き上げ) | loopdetect.go L297 |
| 分散重複の既定 5(3 から引き上げ) | loopdetect.go L298 |
| 全エラー時は予算が倍になる | loopdetect.go L471 |
| 検証エラー署名による短絡 | loopdetect.go L455 |
Sleep 検出器の削除とその被覆の議論 | loopdetect.go L55 |
これらは特定時点の実装例であり、普遍的な契約ではありません。
43.7 よくある落とし穴
繰り返し呼び出しを一律に疑う。 バッチ作業は繰り返し、復帰も繰り返します。ループなのは成果のない繰り返しだけです。
エスカレートしない Nudge。 無制限に発火できる Nudge はログ行であって制御ではありません。
要約なしの ForceStop。 ユーザーには切れた実行と説明ゼロが残ります。ツールなし呼び出し 1 回を惜しまないこと。
契約を直さず検出で凌ぐ。 43.1 はツール検証のバグです。そのために作る検出器は回避策であり、別の場所で誤射します。
正当化できない検出器を残す。 背後にインシデントの無い検出器は未検証の直感であり、いずれ実作業で誤発火します。
除外が雑。 正当に繰り返すツールは重複検出からの除外が要りますが、一律の除外は本物の詰まり検出からもそのツールを外します。除外は狭く。
Kocoro OSS Lab(10 分)
Sleep削除のコメントを読みます。構造に注目:捕まえていた失敗、誤発火、そして今それを覆う 3 つのシグナル。正当化された削除とはこういう形をしています。- 全エラー予算の行を見つけ、なぜ半減ではなく倍増が正しい方向なのか考えてください。
- 自分のツールを 1 つ監査します。必須フィールドのそれぞれについて、ゼロ値で届いたら何が起きるか。答えが 1 つでも「成功して何もしない」なら、43.1 のバグを持っています。
ここだけは押さえる
- 検出は証拠の集約。 9 つの経路があるのは「無進展」に 9 つの形があるからです。
- エラーには成功より広い予算を。 繰り返される失敗はしばしば作業、繰り返される成功はめったにそうではない。
- 停止は自分で説明する。 ForceStop はツールなし呼び出し 1 回を使い、どこまで進んだかをユーザーに伝えます。
- 検出は繰り返しを縛るが、最初の一手は縛れない。 あのゼロバイト書き込みは 4 回目で捕まった——ファイルはとうに失われていた。
- 構成はインシデントから進化する。 壊れたら足し、誤発火が命中を上回ったら削る。
次章:第 44 章は、ツール層の誠実さのもう半分——どの呼び出しが本当に同時実行して安全かを判断する話です。