TL;DR
- 「エージェントに渡すツールを絞れば挙動が安定する」は、たぶん正しい。ただし絞った集合が閉じているかどうかは、絞った件数からは分かりません。
- 一次ソースとして Alibaba の Open Code Review(CLI は
ocr)を固定 SHAa694be56で読みました。ツール定義はinternal/tool/definitions.goにあります(2026-09-16 取得)。 - 同じ「ツール集合」に 3 つの数が同居していました。列挙関数
allTools()が返すのは 7(番兵Unknownを含む)、LLM へ渡す定義ファイルtools.jsonは 6、起動時に実装(Provider)として登録されるのは 5 です。どれも間違っていません。数えている対象が違うだけです。 - そして同じファイルに
Dynamic(name)(「dynamically discovered tools (e.g. MCP)」のためにツールを作る)とIsReserved(name)(組み込み名と一致するかを報告する)が並んでいます。Dynamicは予約名では panic しますが、禁止しているのは名前の衝突であって、集合への追加そのものではありません。 - つまり正確な形は「予約された 6 種 + 名前が衝突しない限り足せる口」です。これは実装の欠陥ではなく、後述するとおりその口が必要だから開いているし、公式ドキュメントも MCP のツールが組み込みの隣に並ぶと明記しています。落差が生まれるのは実装でも公式文書でもなく、「6 個に絞った」という一行に要約された瞬間です。
- 自分のリポジトリでも同型を踏んでいました。失敗台帳 72 行中 12 行が「免除・名指し・通行証」に触れており(
社内データ/ca3b9182f7e1ff9ed0b0310e2f0d9cdb29b61a64/ 2026-09-16 実測)、spec/guard_self_check.mdの## Known gapsには 4 件、自分たちで「このガードは閉じていない」と書いた項目が残っています。 - 持ち帰りは 1 つです。閉じていると書いてあるものは、件数ではなく拡張点から検算する。 「いくつに絞ったか」ではなく「どこから足せるか」を数えてください。
- これはシリーズ 2 本目です。骨格はAIコードレビューの再現性は構造で決まるにあります。
はじめに:「ツールは 6 個です」で話が終わるとき
AI エージェントの設計を議論していると、かなりの頻度でこの形の説明が出てきます。
このレビューエージェントは、LLM に渡すツールを 6 個に絞っている。だから挙動が安定する。
聞いた側は納得します。筆者も納得しました。件数が小さいこと自体は、たしかに良い設計の兆候です。MCPで"運転席と作業者"を分離して事故率を下げるで書いたとおり、許可するツールを絞ることは事故率に効きます。
問題はそこではありません。「6 個」という数を聞いた時点で、多くの人は「その 6 個がすべてだ」と受け取ることです。そして、そう受け取ったまま「ならこのエージェントの挙動は 6 種類の組み合わせで説明できる」という次の推論に進みます。
この記事は、その推論が成り立つかどうかを、実装を読んで確かめた記録です。結論から言うと、件数は正しく、推論は成り立ちません。そしてその理由は「誰かが嘘をついた」ではなく、設計の意図と、外から見た要約の間に構造的に落差が生まれるところにあります。
前提を 2 つ置きます。
- この記事は Open Code Review の採否を勧めません。筆者は本番運用していません。読んだのは固定 SHA
a694be568d9b9a935b2ba11a867d5a91d7ffd833のツリーと、同じツリーに入っている公式ドキュメントだけです。 - CLI のフラグ仕様には依存させません。このリポジトリは日次で push されており、フラグの話はすぐ古くなります。扱うのは構造の読み方だけです。
1. 数えると、ひとつの集合に 3 つの数が出てくる
まず素直に数えます。同じ SHA のツリーから 3 か所を読むと、「ツール集合」に対して異なる 3 つの数が出てきました。
| 数 | どこに書いてあるか | 何の数か |
|---|---|---|
| 7 | internal/tool/definitions.go の allTools() | 名前解決の対象になる識別子。番兵 Unknown を含む |
| 6 | internal/config/toolsconfig/tools.json | LLM へスキーマとして渡される組み込みツールの定義 |
| 5 | cmd/opencodereview/review_cmd.go の buildToolRegistry() | 実装(Provider)として registry に登録されるもの |
3 つとも正しい数です。ずれの理由もソースを読めば分かります。
7 と 6 の差は Unknown です。allTools() は Unknown, TaskDone, CodeComment, FileRead, FileFind, FileReadDiff, CodeSearch を返します。Unknown は「解決できなかった」を表す番兵で、LLM に見せるものではありません。実際 OfName() は一致がなければ Unknown を返す形になっています。
6 と 5 の差は task_done です。buildToolRegistry() が登録する Provider は file_read / file_find / file_read_diff / code_search / code_comment の 5 つで、task_done の実装はここにありません。理由は実行ループ側にあります。internal/llmloop/loop.go の executeToolCall は、registry を引く前に task_done を分岐で処理してしまうからです。ループを止める操作なので、ツールの実装として持つ必要がない。
ここまでは単なる内部事情です。重要なのは次の観察です。
「ツールは 6 個」という一文が真であるためには、どの層の話かを言い添える必要がある。 言い添えなければ、聞いた側は 3 つのうちどれかを勝手に選ぶ。
そして、この時点ではまだ「6 個がすべてか」という問いには答えていません。
2. 予約は「閉鎖」ではなく「衝突の禁止」
definitions.go には、組み込みの列挙と並んで 2 つの関数があります。これが本題です。
2-1. Dynamic() と IsReserved() が実際にやっていること
原文のコメントはこうです(同 SHA、2026-09-16 取得)。
// IsReserved reports whether name matches any built-in tool name (including Unknown).
// Dynamic creates a Tool with the given name for dynamically discovered tools (e.g. MCP).
Dynamic(name) は、空文字なら panic、IsReserved(name) が真なら panic、それ以外ならその名前のツールを作って返します。
つまり definitions.go は、組み込みの一覧を持ちながら、同じファイルで「一覧の外の名前でツールを作る手段」を公開しています。そして IsReserved が見ているのは名前が一致するかどうかだけです。用途も、引数の形も、権限も見ていません。
ここが、この記事でいちばん持ち帰ってほしい区別です。
予約語の仕組みは「集合を閉じる」ものではなく、「名前空間の衝突を禁じる」ものである。 前者だと読むと、集合の外側に開いた口が見えなくなる。
2-2. なぜ名前の衝突だけを見れば足りるのか
ここで「チェックが甘い」と評価するのは早計です。実装を追うと、名前の衝突だけを禁じれば足りる理由がありました。
前節で見たとおり、executeToolCall は tool.OfName(call.Function.Name) で名前を解決し、それが TaskDone なら registry を引かずに分岐します。もし外部のツールが task_done を名乗れたら、そのツールは呼ばれた瞬間にループ終了として解釈され、自分の実装には一生到達しません。code_comment を名乗れば、コメント収集側に化けます。
だから IsReserved は「安全チェック」ではなく、ディスパッチの一意性を守る不変条件です。名前が衝突しないことが、名前ベースの分岐が壊れないことの必要条件になっている。この目的に対しては、名前だけを見るのが正しい。
設計は正しく、要約は落差を生む。 両立します。そしてこの両立こそが、外から読む側が注意すべき形です。「予約語がある」と聞いて「集合が固定されている」と読むのは、実装が間違っているからではなく、予約語という語が日常的に持つ含意のほうが広いからです。
3. では、どこから足せるのか
口が開いているなら、次に数えるべきは「件数」ではなく「入口の数」です。同じツリーと公式ドキュメントから、3 つ見つかりました。
3-1. MCP サーバー(internal/mcp/provider.go)
Dynamic() の唯一の呼び出し元は MCP のアダプタでした。Provider.Tool() が tool.Dynamic(p.toolName) を返し、RegisterAll() が MCP サーバーの提供するツールを組み込みと同じ registryへ登録します。関数コメントも明快です。
// Tools whose names conflict with built-in or already-registered tools are skipped with a warning.
スキップの条件は 2 つ、組み込み名との衝突と登録済み名との衝突。どちらも名前です。衝突しなければ登録され、CollectToolDefs() が LLM へ渡すツール定義の配列に追加します。
ここで実務上いちばん効くのは、設定の置き場所です。公式ドキュメント(pages/src/content/docs/en/mcp.md、同 SHA)は、MCP サーバーの設定がユーザー設定ファイル ~/.opencodereview/config.json に保存され、こう書いています。
Because this is user configuration, it applies across repositories
つまりレビュー対象のリポジトリの中を全部読んでも、そのレビューが実際に何を呼べるかは分かりません。リポジトリに commit された設定ではなく、実行した人のホームディレクトリにあるからです。CI でもローカルでも同じバイナリが動きますが、手元で足したツールはリポジトリの履歴に一切現れない。
これは権限の話に直結します。誰の環境で走ったかで到達範囲が変わる構造についてはAIエージェントの権限は影響範囲で設計するに分けて書きました。
3-2. allowlist の既定値は「全部」
同じドキュメントには tools というフィールドがあり、登録するツール名の allowlist として働きます。ただし既定値の説明はこうです。
Allowlist of tool names to register. Empty = register every tool the server offers.
設定しなければ、そのサーバーが提供するツールは全部入る。 allowlist は存在しますが、opt-in ではなく opt-out です。これは事実の記述であって、良し悪しの評価ではありません。ドキュメントは「サーバーが必要以上に提供する場合は allowlist を設定せよ」と明示的に勧めてもいます。
ただ、設計を外から検算する側にとっては意味が変わります。「allowlist の仕組みがある」は「集合が絞られている」を意味しません。空の allowlist は、最も広い集合と同じ挙動になるからです。
3-3. --tools は、件数を変えずに意味を変えられる
3 つ目が、いちばん見落としやすい形でした。公式ドキュメント(pages/src/content/docs/en/tools.md、同 SHA)にこうあります。
To override the tool registry, pass --tools <path> to a JSON file with the same shape as the embedded one. This lets you disable a tool, edit a description, or add a new tool backed by an existing provider.
注目したいのは edit a description です。LLM がツールをどう使うかは、名前とスキーマと説明文で決まります。説明文を差し替えれば、ツールの件数も名前も実装も変わらないまま、モデルの振る舞いが変わりえます。
「6 個に絞った」という記述は、この操作の前後でどちらも真です。件数を検算に使うと、この差分は一生見えません。
3-4. 3 つの入口を並べる
| 入口 | 何が増えるか | 名前の検査 | リポジトリの履歴から見えるか |
|---|---|---|---|
| MCP サーバー | ツールそのもの | 衝突のみ(スキップ+警告) | 見えない(ユーザー設定) |
tools allowlist 未設定 | サーバーの提供分すべて | 同上 | 見えない(ユーザー設定) |
--tools <path> | 説明文・有効無効・既存 Provider への別名 | 件数は変わりうるが説明文は無検査 | 渡し方による |
「絞りきれていないもの」は、絞った集合の中身ではなく、集合の定義そのものが実行時に決まるという点にありました。
4. Freeze() は何を保証しているか
ここで公平を期すために、閉じている部分も書いておきます。
Registry には Freeze() があり、凍結後に Register() を呼ぶと panic します。そして internal/agent/agent.go の Run() の中で a.args.Tools.Freeze() が呼ばれます(同 SHA、Run は 278 行目から、Freeze 呼び出しは 316 行目)。
呼び出し順を cmd/opencodereview/review_cmd.go で追うと、こうなっていました。
buildToolRegistry()で組み込み 5 つを登録するinitMCPClients()が、同じ registry へ MCP のツールを登録するCollectToolDefs()が LLM へ渡す定義配列に MCP 分を追加するag.Run()の中でFreeze()が呼ばれ、以後の追加が禁止される
つまり Freeze() が保証しているのは、レビュー実行中に集合が変わらないことです。これは実行中の再現性にとって重要な保証で、実際そう機能しています。
保証していないのは、実行と実行の間で集合が同じであることです。
閉鎖はライフサイクルの性質であって、メンバーシップの性質ではない。 「凍結されている」は「中身が決まっている」と同義ではない。
この区別は AI レビューの再現性にそのまま効きます。同じ diff を 2 回レビューして違う結果が出たとき、モデルの揺らぎを疑う前に、2 回のツール集合が同じだったかを確かめられる形になっているか。ここが確かめられないなら、再現性の議論は始まりません。
5. 同じ形を、自分のリポジトリで踏んでいた
ここまでは他人のコードの読解です。では自分はどうか、を数えました。以下はすべて本リポジトリの ref ca3b9182f7e1ff9ed0b0310e2f0d9cdb29b61a64(2026-09-16)時点の実測です。
- 失敗台帳
spec/article_failure_ledger.mdは 72 行。うち「免除 / 名指し / 通行証」のいずれかに触れている行が 12 行。 spec/guard_self_check.mdの## Known gaps節には 4 件、自分たちで「このガードは塞げていない」と明記した項目がある。scripts/配下のガードスクリプトは 19 本。
12 行のうち、この記事の論点にいちばん近いのは台帳 L-0078 です。要旨はこうでした。
分類の妥当性を「独立に書いた 2 実装で突き合わせている」と説明していた箇所がありました。判定側と検算側は別の関数で、別のロジックで書いてあった。ところが両方とも同じモジュール定数から接頭辞の一覧を読んでいました。定数へ要素を 1 つ足せば両側が同時に傾くので、突き合わせは何も確かめていません。しかも足す要素には記事のディレクトリ名をそのまま書けたので、その定数は事実上「特定の記事を検査から外す通行証」でした。
構造を並べると同じ形です。
| Open Code Review | 本リポジトリ(L-0078) | |
|---|---|---|
| 閉じていると読める記述 | 「組み込みツールは 6 個」 | 「独立な 2 実装で突き合わせている」 |
| 実際に閉じている範囲 | 名前空間の衝突禁止 / 実行中の凍結 | 関数としての分離 |
| 開いていた拡張点 | 名前が衝突しない動的登録 | 双方が読む共有定数 |
| 件数を数えても見えない理由 | 件数は登録前の定義しか数えていない | 実装の本数は材料の独立性を測っていない |
そして是正のときに学んだ一般則も同じ形をしています。独立性は「別の関数に書いたか」ではなく「別の材料から導いたか」で決まる。詳しくは独立測定によるクロスチェックに書きました。
翻訳するとこうなります。集合の閉鎖性は「何個に絞ったか」ではなく「どこから足せるか」で決まる。
なお、この対応づけは筆者の読み取りです。Open Code Review 側に不具合があると言っているのではありませんし、どちらかの実装を評価してもいません。同じ読み違いの形が、他人のコードと自分のコードの両方に出たという観察です。
6. 拡張点から検算する
では実務で何をするか。件数を数える代わりに、次の 4 つを 1 行ずつ書き出すだけで足ります。
- 列挙されている集合はどれか。 定義ファイルか、コードの列挙か、実装の登録か。層を名指す。
- その集合に足す手段はいくつあるか。 設定ファイル、プラグイン機構、コマンドライン引数、環境変数。ゼロなら、ゼロだと言い切れる根拠を書く。
- 足したことは、どこに記録されるか。 リポジトリの履歴に残らないなら、レビューの再現条件はリポジトリの外にある。
- 集合が固定されるのはいつか。 起動時か、実行開始時か、実行中も可変か。固定される瞬間より前に何が走るかを見る。
1 本目で書いた二面検証の形に寄せるなら、右側の列はこうなります。宣言に反する拡張を 1 つ実際に足してみて、宣言側が変わるか。変わらないなら、その宣言は今日まで何も保証していません。
ここは AI かどうかとは無関係な、ただの境界条件の話です。ただ AI エージェントでは集合が実行時に組み上がるため、静的な設定ファイルを読んだだけでは範囲が確定しません。だから明文化しておく価値があります。
根拠から言えることと言えないことを分けておきます。この記事が一次ソースで確認したのは、特定の 1 実装において、予約済みの組み込みツールと動的追加の口が同じ名前空間に同居しており、名前の衝突だけが検査されているという事実までです。そこから「件数ではなく拡張点を数えよ」と一般化するのは、自リポジトリで同型の読み違いを 12 行記録している経験に基づく推論であって、1 実装の読解で証明された命題ではありません。それでも実務の指針としては、この形で運用する価値があると判断しています。
まとめ
主張をもう一度置きます。
エージェントに渡すツールを予約制にしても、拡張点が開いていればその集合は閉じていない。閉鎖性は件数ではなく、入口の数と、集合が固定される瞬間で決まる。
そして、この記事が扱わないと決めたことも書いておきます。
- Open Code Review の採否は判断しません。本番運用しておらず、読んだのは固定 SHA のソースと同梱ドキュメントだけです。
- MCP サーバーを使うべきか否かも判断しません。ドキュメントが挙げる用途(issue 参照 / 社内ドキュメント / カスタム解析)は、いずれも diff の外の文脈を取りに行くという明確な目的を持っています。口が開いていることは欠陥ではありません。 開いていることを数えずに「閉じている」と言うのが問題です。
- 性能比較はしません。同梱のベンチマークは Alibaba 自身の公開値で、第三者の再現ではないためです。
明日からやれること
- いま使っている AI レビューについて、「ツールを足す手段はいくつあるか」を 1 行で書く。 書けないなら、その集合の件数は根拠になりません。
- 足したものがリポジトリの履歴に残るかを確かめる。 残らないなら、「同じ設定で再現した」は検証できない主張です。
Known gapsに相当する節を自分の規約に作る。 うちは 4 件書いてあります。塞げていないものを書ける場所がないと、「塞いだ」しか書けなくなります。
次回、シリーズ 3 本目は「レビューを外注せず、レビューの段取りだけを外注する」を扱います。責務の切り方の話なので、Agents APIで何が外注できるかと隣接します。
FAQ
Q. ツールを絞ること自体には意味がないのですか?
意味はあります。渡すツールが少ないほどモデルの選択肢は減り、トークンも減ります。この記事が否定しているのは「絞った」ことの価値ではなく、件数を閉鎖性の証拠として使うことです。絞ったうえで「足す手段がゼロである」を別途示せるなら、その集合は閉じています。
Q. 予約語の仕組みがあれば、名前の衝突以外も防げるのではないですか?
読んだ実装では、予約の判定は名前の一致だけを見ていました。これは実装の不足ではなく、名前ベースのディスパッチが壊れないための不変条件として必要十分だからです。用途や権限の制約が要るなら、それは予約とは別の層で書くことになります。
Q. MCP を使うと再現性が下がりますか?
一概には言えません。確かなのは、再現条件がリポジトリの外に出ることです。設定がユーザー設定ファイルに置かれる場合、レビュー対象のリポジトリを読んでも、そのレビューが何を呼べたかは分かりません。再現性を担保したいなら、設定の所在を記録に含める設計が別途必要になります。
Q. 「閉じているか」はどうやって機械で検査しますか?
完全な検査は難しいです。うちで実際にやっているのは、宣言に反する拡張を 1 つ注入して、宣言側や検査側が落ちるかを見ることだけです。落ちなければ、その宣言は検出能力を持っていません。この手順は spec/guard_self_check.md に規約として置いてあります。
Q. 自分たちの規約に Known gaps を書くと、弱点を公開することになりませんか?
なります。そのうえで書く価値があると判断しています。書かないと「塞いだ」しか記録に残らず、塞げていないものが塞いだことになってしまうためです。うちの台帳 72 行のうち相当数は、この「記述だけが実装より強い」型でした。
References
- Open Code Review リポジトリ(Alibaba / Apache-2.0): https://github.com/alibaba/open-code-review
- 組み込みツール定義
internal/tool/definitions.go(固定 SHAa694be56、本記事の中心資料): https://github.com/alibaba/open-code-review/blob/a694be568d9b9a935b2ba11a867d5a91d7ffd833/internal/tool/definitions.go - MCP アダプタ
internal/mcp/provider.go(同 SHA): https://github.com/alibaba/open-code-review/blob/a694be568d9b9a935b2ba11a867d5a91d7ffd833/internal/mcp/provider.go - ツール定義ファイル
internal/config/toolsconfig/tools.json(同 SHA): https://github.com/alibaba/open-code-review/blob/a694be568d9b9a935b2ba11a867d5a91d7ffd833/internal/config/toolsconfig/tools.json - 公式ドキュメント Tools(同 SHA、同梱): https://github.com/alibaba/open-code-review/blob/a694be568d9b9a935b2ba11a867d5a91d7ffd833/pages/src/content/docs/en/tools.md
- 公式ドキュメント MCP Servers(同 SHA、同梱): https://github.com/alibaba/open-code-review/blob/a694be568d9b9a935b2ba11a867d5a91d7ffd833/pages/src/content/docs/en/mcp.md
- 実行ループ
internal/llmloop/loop.go(同 SHA): https://github.com/alibaba/open-code-review/blob/a694be568d9b9a935b2ba11a867d5a91d7ffd833/internal/llmloop/loop.go - Model Context Protocol 公式サイト: https://modelcontextprotocol.io/
- Open Code Review 公式ドキュメントサイト: https://open-codereview.ai/docs
