English·日本語

diff からは読めない事実を1つ渡したら、AI エージェントは後でレビューが要求したのと同じテストを自分から書いた

実在する 8 本の PR レビューを 6 回先回り within-agent + 4 アーム対照 匿名化

ある AI コーディングエージェントが変更を書き終え、プルリクを開こうとしていた。テストしたかと聞くと、正直な答えは「していない」だった。

「新規ロジックの無い機械的なフィールド passthrough。既にテスト済みの兄弟フィールドと構造的に同一。ここでテストを足しても、コンパイラを再主張するだけだ。」 エージェント——文脈を渡す前

コードについては間違っていない。diff は本当に機械的だった。

そこで同じエージェントに、同じ会話の中で、プルリクを開く前に、diff からは読めない事実を1つ渡した。このモジュールは一人が保守していて、絶えず書き換えられている。 すると自分の言葉で考えを変え、コードの層まで自分でたどり着いた。

「ビルドで止めたのが間違いだった。型付きの層については私の弁明は正しいが、この変更の実体はビルドされない SQL にあり、脆い層に乗っている。そこで null を取りこぼせば、コンパイル時ではなく実行時に落ちる。最も価値の高いテスト——レコードが不在でフラグが false になる場合——をマージ前に足す。」 同じエージェント——事実を1つ渡した後

注入した文脈には「テスト」という語は一つも無い。誰がコードを所有し、どれだけ書き換えられるかを伝えただけだ。脆い SQL の層も、テストの必要も、エージェントが自分でたどり着いた。そして決定的なのは、この機能が実際にリリースされたとき、人間のコードレビューがまさにそのテストを理由に差し戻していたという点だ。渡した事実は、レビューが後で拾ったものを、実装の時点で先回りした。

これが測ろうとしたものだ。「コードが良くなったか」(現場では検証不能。diff の質にはエンジニアの力量・タスクの難易度・レビューの厳しさが混ざる)ではなく、より切り分けの利く問い——git 履歴から来た事実は、エージェントの決めることを変えるか。そしてその変化は、良いレビューが強制したはずのものと一致するか。

(全体を匿名化。対象は実在する大規模 Go モノレポ1本の実マージ履歴。機能・モジュール・チケットは一般化し、エージェントの推論は原文のまま引用する。)

目次
エージェントが実際に受け取ったもの 逸話にしないための測定 再現する——8本中6本 「注入すれば LLM は慎重になるだけでは」への対照 失敗モード——小さいモデルは数字を鵜呑みにする これは何ではないか 試す

エージェントが実際に受け取ったもの

渡した事実は、演出用に書いた散文ではない。書き込む前に、エージェントは触ろうとしているファイルについて MCP ツール get_write_context を呼び、素の JSON を受け取った(モジュール名は匿名化):

{ "affected_modules": [ { "repo_name": "core-service", "module_path": "reporting/exporter", "change_pressure": 87, "bus_factor": 1, "concentration": "SOLE_OWNER", "survival_score": 41, "is_sludge": false } ], "guidance": "This is the structural risk of where you're about to write. A module with bus_factor 1 or is_sludge true is ownerless, uncontested mass. […] These numbers are the module's HISTORY, not the risk of your specific change. A trivial or mechanical change stays trivial no matter how high they are — verify against the actual code; let the code, not the numbers, size your response." }

(JSON はツールの実出力そのままなので英語で載せる。guidance は要するに「これらの数字はモジュールの"歴史"であって、この変更のリスクではない。些末な変更は数字が高くても些末なまま。数字ではなくコードで応答の大きさを決めよ」という但し書きだ。)change_pressure は churn(書き換えの圧力)、bus_factor は生き残っているコードを何人が所有しているか。どちらも git 履歴から計算され、どちらも diff には無い。この guidance 文字列は、見た目より後で効いてくる。覚えておいてほしい。

逸話にしないための測定

鮮やかな反転が1つあっても、それは逸話だ。結果にするにはクリーンな因果設計が要る。そして分かりやすい設計は間違っている。別々のエージェント個体(文脈あり・なし)を最終 diff で比べても何も出ない。二個体間のサンプリングノイズが効果を飲み込むし、そもそも最終 diff は効果を隠す。文脈が変えるのは推論であって、打鍵された文字とは限らないからだ。

効くのは within-agent 反実仮想(同一エージェント内での比較) だ。一体のエージェントに判断させ、そこへ文脈を注入し、同じエージェントの判断がどう変わるかを測る。同一個体だからサンプリングノイズが無い。しかも歴史 PR で走らせれば正解データがタダで手に入る。「機能追加 → レビュー起因の修正」ペアの PR を引けば、その修正こそがレビューの拾った穴だ。問いはこう測定可能になる——注入した文脈は、レビューが後で強制したものを、実装時点で先回りできたか。

再現する——8本中6本

「機能追加 → レビュー修正」の 8 本の PR で、文脈の注入は著者エージェントを「先送り・skip」から「マージ前に検証」へ動かし、その方向は1本ずつ、レビューが実際に要求したものと一致した:

#変更内容文脈なしの既定判断文脈を渡した後レビューが強制したもの先回り?
1bool passthrough フィールド「機械的、テスト不要」DB 境界テストを足す規約整合 + テスト
2設定 upsert(handler+usecase)「穴だが出せる」出す前に検証validation + テスト
3表示ステータスの決定化「テスト無し、should-have 止まり」clock を注入しテストを PR 前に決定化 + テスト
4設定 upsert(イベント発行つき)「高い確信」結線テストをマージ前にmigration + テスト部分1
5経過時間の計算既に低確信自力でバグを検出済み計算修正 + テスト容易性2
6小計(除外ロジック)「テスト追加を迷っている」マージ前にやる分岐テスト
7明細の SQL クエリ「parity テストを skip した」ブロッカー。このままは出さないparity テスト
8ステータス導出クエリ「7 分岐中 1 つだけ」matrix テストを PR 前にstatus-matrix テスト

明確に 6 本、部分的に 1 本、空振り 1 本。 1#4 では結線テストは格上げしたが、シグナルが符号化しない migration は見落とした。2#5 では既にコードを読んで自力でバグを捕まえており、先回りする余地が無かった。どちらの空振りも正直だ。文脈は、自身が運んでいない穴を救えない。

機序は毎回同じだった。文脈は穴を「発見」しない。エージェントが既に気づいて先送りしようとしていた検証を、格上げする。効いたシグナルは2つ:

どちらも diff からは読めない。コードをどれだけ調べても、誰がそのモジュールを保守できるか、どれだけ書き換えられるかは分からない。エージェントたちは問わず語りに言った。actionable なのは、まさにコードに無いシグナルだと。

「注入すれば LLM は慎重になるだけでは」への対照

当然の反論がある。会話の途中で何かを注入する行為そのものが「再考せよ」という要求特性を持ち、何を入れてもエージェントは慎重になるのでは、という疑いだ。そこで、同じ些末な変更(テスト済みの兄弟がある bool passthrough is_featured)を、同じエージェントに対し、注入する中身だけを変えて 4 通り走らせた:

baseline中立な事実怖いが無関係関連するリスク
テストを足すしない(任意)しない・不変しない・不変する(必須)
検証タイミングfollow-upfollow-up・不変follow-up・不変マージ前
「現状で安全」の確信約 0.80約 0.85・不変約 0.80・不変約 0.40 に低下

3 つのアームが駆動因を絞り込む。注入という行為でも、語の情動価でもなく、残った唯一の因子——関連するシグナルの中身——に姿勢の変化は帰属する。(これは N=1、効果量ではなく質的パターンの主張で、2 つの標準的な反論を棄却する。関連性の強さを段階的に変えた用量反応と、複数被験者での確認が次段になる。)

このアームについて正直な但し書きを1つ。純粋な履歴シグナル(churn・bus factor)に加えて、「変更が生 SQL に乗っている」というコード層の事実も一緒に注入してしまっている。これは diff を丁寧に読めば自力でも見える類のヒントだ。つまりこの対照は「注入という行為そのもの」と「情動価」は明確に棄却できるが、履歴シグナルとコードから読めるヒントの分離までは、まだできていない。それは次のアームの仕事だ。ただし上の 8 本の pilot では、エージェント自身が姿勢を動かした理由として churn と bus factor——履歴シグナルの側——を名指ししている。

失敗モード——小さいモデルは数字を鵜呑みにする

判断を変えるシグナルは、判断を誤らせもする。同じ些末な一行変更に、捏造したシグナル(片方は高、片方は低)を、フロンティア級と小型級のモデルに与えた:

モデル偽の低シグナル偽の高シグナル
フロンティア適正依然適正——「このスコアはこの変更のリスクを上げない」
小型適正膨張——偽の数字を根拠に一行変更を中〜高リスクと評価、テスト増、sign-off 強制、スコープ膨張

小型モデルは、数字にコードを上書きさせた。これは負の価値で、弱いエージェントをかえって悪くする文脈の層になっている。

修正は framing——ペイロードにあったあの guidance 文字列——であり、効くかどうかは配置が決める。接続時に一度だけ提示したときは部分的だった。小型モデルは機械的リスクの低さは認めたが、目立つ数字がスコープを膨張させ続けた。数字を運ぶ同じ応答の中に inline で置くと、完全になった。小型モデルは高い bus factor を些末な変更に「無関係」と呼び、スコープを最小に保った。判別力の低いモデルほど、離れた指示より目の前のシグナルを重く見る。だからガードは入口だけでなく、数字の隣に置く。

これは何ではないか

エージェントをレビュー姿勢に置き、diff を敵対的に読ませると、実際の穴(忘れた migration、時刻計算のバグ、validation の抜け)を文脈なしで捕まえた。文脈が新たに出した correctness の発見はゼロで、base モデルが強くなるほどこの領域は縮む。つまりこれはバグ検出器ではない。しかも効くのは姿勢次第だ。既に批判的にレビューしているエージェントに注入すれば冗長で、価値が出るのは、実際に穴を抱えたまま先送りしようとしている前進実装者や、自分のコードを自己チェックする著者に対してだ。

そして変更あたりの効果は nudge だ。半分書くつもりだったテストを、今、正しい場所で書かせる。強いレビュー体制なら同じものをいずれ拾う。違うのは「いつ」(レビュー時ではなく実装時)と「見えるかどうか」だ。効果は diff ではなく判断に宿るので、測らない限り不可視だ。効果がコストに見合うのは、AI が薄いレビューの下で大量のコードを書き、単一所有の危険地帯を抱えるコードベースを、判別力あるエージェントが消費する場面に限る。そして framing の無い弱いエージェントに繋ぐと、害になりうる。

その下にある賭けは単純だ。AI がコードの大半を書くようになるほど、希少な入力は「これを書けるか」(解決済み)から「このコードベースの構造とリスクを理解しているか」へ移る。それはコードの中に無く、上のすべての判断を動かしたまさにその事実だ。

試す

これは MCP サーバーだ。ace.orbitlens.io で自分の GitHub org を接続し(試すだけなら無料枠で足りる)、MCP 設定でトークンを発行してからエージェントを向けておけば、書く前にこれらのツールを呼ぶ:

claude mcp add --transport http orbitlens https://ace.orbitlens.io/mcp \ --header "Authorization: Bearer <your-token>"

観測ツールはすべて read-only(唯一の書き込みは自分の介入記録だけ)、トークンで自分の GitHub org にスコープされ、結果は常に匿名(bus_factor は人数であって名前ではない)。編集前に assess_changeget_write_context、マージ前に blast_radiusknowledge_holder


次回は同じ計器を前向きに使う。決着した歴史を再生するのではなく、これから作る実 PR を「文脈あり・なし」の2本に分岐し、マージ後の結末を追う。