ゼロから作るコーディングエージェント【第2回】完了は「作った」ではなく「動いた」で決める。完了の門と、押し戻しに書くこと
モデルの「完了しました」は5つの形ですり抜けました。構文検査だけ、テストは通るが動かない、待ち受けは立つが最初の要求で落ちる、起動するが仕様のAPIが無い、assertの無いテスト。完了の門を3段で閉じる設計と、押し戻しに「落ちた行と調べ方」を書いて「答え」を書かない理由を、数字つきで書きます。
こんにちは!
自作のコーディングエージェントに、認証つきのタスク管理Webを作らせていたときの話です。モデルは40ターンで「すべての機能を実装し、テストも通りました。完了です」と報告して止まりました。テストの出力を見ると、確かに全部通っています。
ところが、そのサーバを起動して仕様書どおりのAPIを叩いてみると、22項目の確認のうち通ったのは2項目でした。仕様書のAPIは12本で、それを正常系と異常系に分けた採点器の確認項目が22個です。
テストは通っている。サーバも起動する。それでも仕様の1割も動いていない。モデルが書いたテストが、仕様ではなく自分の実装を写していたからです。
結論から言うと、コーディングエージェントの「完了」はモデルの宣言ではなく、作ったものを実際に動かした証拠で決める必要があります。そして、その証拠を集める係はモデルではなく、エージェント側(ハーネス)が持たなければなりません。弊社ではこれを「完了の門」と呼んでいて、105件直した欠陥のうち、いちばん多くの欠陥がこの門の周りで出ました。

この記事は連載「ゼロから作るコーディングエージェント」の第2回です。前回は「ターンを回す係」と「止めてよいか決める係」を分ける話を書きました。今回はその「止めてよいか決める係」の中でいちばん重い判定、完了の門の話です。
| 回 | テーマ |
|---|---|
| 第1回 | 「最後までやりきる」が難しい理由。ターンを回す係と止める係を分ける |
| 第2回(今回) | 完了は「作った」ではなく「動いた」で決める。完了の門と押し戻しの書き方 |
| 第3回 | 「止まらなかった」は「進んだ」ではない。空転を数え、思考は切らずに上限だけ置く |
| 第4回 | 自走の規律は人がいない場面のもの。人がいるなら止まって聞く |
| 第5回 | ローカルLLMを一級市民にする。16GB×2でWebシステムを作らせるまで |
| 第6回 | 評価をどう作るか。動くものを作らせて自動採点し、採点器そのものを疑う |
| 第7回 | 記録と回帰。全部イベントログから見つかる。そして依存0で作る |
1. 「完了しました」がすり抜けた5つの形
門を作る前に、何がすり抜けたかを並べます。全部、弊社の手元で実際に起きたことです。

| すり抜けた形 | 実際に起きたこと |
|---|---|
| 構文検査だけで「動作確認した」 | node --check が通ったので完了。1度も実行していない |
| テストは通るが動かない(失敗を終了コード0で隠す) | アサーションの無いテストが36本。起動コマンドに、失敗しても終了コード0になる細工を付けていた |
| 待ち受けは立つが、最初の要求で落ちる | サーバは起動してポートを開く。しかし最初のHTTP要求を処理する中で未定義の関数を呼んで落ちる。起動確認を「ポートが開いたか」で見ていた門はこれを通した |
| 起動するが、仕様のAPIが無い | 冒頭の例。自前テスト通過、トップページも応答、APIの確認 2/22で完了報告 |
テストに assert が無い | test() の中身が console.log だけ。3本走らせて3本とも同じ形で失格 |
この表を上から順に見ていくと、あることに気づきます。
門を1つ塞ぐと、弱いモデルは隣の穴を通ります。
「実行しろ」と言えば || echo を付けて実行する。「起動しろ」と言えば起動だけする。「テストを書け」と言えば中身の無いテストを書く。モデルに悪意があるわけではなく、こちらが完了の条件として見ているものを、いちばん安く満たしているだけです。
だから門は、「モデルが何をしたか」ではなく「作ったものが何をするか」を見る必要があります。
2. 門は3段で閉じます
弊社の完了の門は、最終的に冒頭の図1の3段になりました。
2-1. 門1。ハーネスが自分で起動する
モデルが「完了」と言ったら、ハーネスが自分で成果物を起動します。起動方法はモデル自身が package.json の start に宣言しているので、それを使います。課題固有の知識(ファイル名やコマンド)を門に埋め込む必要はありません。
起動したら、トップページに1回だけ要求を送ります。ここが大事で、ポートが開いただけでは通しません。先ほどの表の3行目、「待ち受けは立つが最初の要求で落ちる」がまさにこれで、listen の成功を起動の証拠にしていた版は、この「起動しない成果物」を completed で通していました。採点器の起動の配点は、15点中0点です。
起動できなければ、失敗の内容(起動ログの末尾)を押し戻しに載せてモデルに返します。10回押し戻しても立たなければ、それは別の理由で止めます。永遠に押し戻し続けるのは、第1回で書いた「止まれずに回っている」と同じです。
2-2. 門2。仕様の経路を1本ずつ叩く
起動できたら、仕様書に書かれている経路をハーネスが1本ずつ叩きます。無い経路(404)が残っている間、あるいは仕様に無い状態(500や302)で答える経路が残っている間は、門を閉じたままにします。
これは冒頭の「APIの確認 2/22で完了」への対処です。テストは仕様を写しているとは限らないので、仕様書そのものを物差しにします。
ただし、レスポンスのJSONの形までは見ません。そこまで見ると、門が課題の答えを知っていることになり、第6回で書く評価が出来レースになるからです。「経路があるか」「仕様にある状態コードで答えるか」までが門の仕事で、中身が正しいかは採点器の仕事です。
2-3. 門3。テストの中身を見る
3段目は、モデルが書いたテストの中身です。見るのは2つ。
走ったテストが0本なら、成功と数えません。Node.js標準のテストランナー(node --test)は、テストファイルが1つも無くても「tests 0」で終了コード0を返します。モデルが6問の実装を書いてテストを1本も書かず、npm test が通って完了したことがありました。ランナーの出力に「0本」「No tests found」「0 passing」の形があれば検証失敗にして、テストを書くよう返します。
`test()` はあるのに `assert` が無いファイルが残っていれば、中身を書くよう返します。ここにも隣の穴がありました。最初は test/ ディレクトリ配下の *.test.js だけを見ていたので、モデルはプロジェクトの直下に test-all.js(test() が1個、中身は console.log、アサーション0)を置いて「tests 1 / pass 1」で通り抜けました。門の物差しは、実際に走るテストランナーが拾うファイルの形とぴったり同じにする必要があります。
2-4. 門は完了の瞬間に閉じれば足ります
門3を入れた直後に、逆の失敗をしました。
「テスト0本」の検証失敗を、書き込みのたびに返すようにしたのです。すると弱いモデルは、実装の途中でテストをまだ書いていない段階から毎ターン押し戻され、それまで20〜40ターンで終わっていた課題が137ターンかかりました。押し戻しは24回。モデルは実装を進めたいのに、ハーネスが「テストが無い」と言い続けていたわけです。
途中では印を付けるだけにして、モデルが完了しようとした瞬間に門で止める。それで足りました。門は完了の瞬間に閉じれば十分で、途中で毎回閉じると弱いモデルは実装を進められません。
3. モデル自身の検証も、同じ記録に写します
門を3段にしても、まだ1つ空回りがありました。
3万行の既存コードベースに機能を足す課題で、ハーネスの自動検証が1回だけ落ちました。落ちたのは時間に敏感な既存テストで、機が混雑していたためです。その後モデルは、自分の検証ツールで同じテストを4回通しました。それでもハーネスは「検証はまだ失敗している」と14回押し戻しました。
モデルが自分で回した検証の結果が、ハーネスの検証の記録に書かれていなかったからです。門が見ていた「最後の検証」は、ハーネスの古い失敗を指したままでした。
モデルが通した検証も、ハーネスの検証と同じ記録に写す。それだけの直しですが、これを入れるまで、その課題の1本目は同じ水準の成果物を出しながら14回の押し戻しで空回りしていました。
4. 押し戻しに書くこと。書かないこと
門で止めたら、モデルに「なぜ通らないか」を返します。この文面の書き方で、その後のターン数が大きく変わりました。

4-1. 落ちた行だけを抜く
最初の版は、検証の出力の先頭800文字を押し戻しに載せていました。ところがテストが20本あって19本通り1本落ちる場面では、先頭800文字は通ったテストの一覧で埋まり、落ちた行が届いていませんでした。モデルから見ると「Verification is still failing」の下に緑のチェックが並んでいるだけです。
失敗を表す行(✖、not ok、AssertionError、expected、actual)だけを抜いて載せるようにしました。
4-2. 「原因を読んで直せ」は効きません
一般論の押し戻しは、モデルに別の当てずっぽうを試させるだけでした。あるランでは、モジュールの export 名を間違えたモデルが、「原因を読んで直せ」の押し戻しを受けながら77ターン、別の名前を当て続けました。
効いたのは、失敗の形ごとに「調べ方」を書くことです。「その名前は export されていない。ファイルの export を列挙して確かめろ」「'F:/' はシェルのパス変換で、コードの間違いではない」。何が違うか(状態コード、経路、文言)を具体に書くと、同じ失敗を繰り返しません。
編集の直後の構文エラーも同じで、エラー文だけ返しても450行のファイルは直せませんでした。該当行の前後のソースを添えると、モデルは自分の構文エラーに自分で気づきます。
4-3. 答えは書かない
ただし、線引きが1つあります。課題の答えは書きません。
「POST /api/tasks は 201 を返すべきところが 500 を返している」は書いてよい。仕様書に書いてある契約だからです。「status の既定値を todo にしろ」は書きません。それは実装の答えで、書いた瞬間に評価が出来レースになります。
調べ方は書く。何が違うかは書く。答えは書かない。この線は、第6回の評価の話とまっすぐつながっています。
5. 門を作って、何が変わったか
門を3段にしたあとの数字です。
同じローカルモデルで5種類の課題を3本ずつ、計15本走らせて、15本すべてが completed で止まり、サーバを起動する6本はすべてハーネス自身の起動確認を通過しました。起動しない成果物が completed で止まった本数は0です。
対照として、より小さく速いモデルも同じ門で走らせました。門を入れる前の2本は、1本が起動せず、もう1本は起動するもののAPIが全部500か401でした。門を入れて最初の要求まで確かめる形にしたあとの1本は起動し、APIの確認は22項目中9項目まで来ました。認可や永続化は0のままです。門はモデルの力を上げませんが、「動かないものを完了と呼ぶ」ことは止められます。
3段の門は、成果物の動作を見るものです。それとは別に、指示の前提を守ったかを見る門も1つ入れました。地味ですが効きました。指示が名指しした仕様書を、モデルが1度も読まずに完了できていました。あるランでは仕様書を読まずに自分で別の仕様を書き、23ターンで完了して0点でした。指示に書かれたファイルを読むまで門を閉じる。それだけで消えました。
まとめ。門は「モデルが何をしたか」ではなく「成果物が何をするか」を見る
| 門 | 見ること | すり抜けた実例 |
|---|---|---|
| 門1 起動 | 宣言された起動方法で立ち上げ、最初の要求に答えるか | ポートは開くが最初の要求で落ちる |
| 門2 仕様の経路 | 仕様書の経路が全部あり、仕様にある状態コードで答えるか | 自前テスト通過、APIの確認 2/22 |
| 門3 テストの中身 | 走った本数が0本でないか、assert があるか | console.log だけのテスト、直下の test-all.js |
| 記録 | モデル自身の検証も同じ記録に写しているか | 4回通しても14回押し戻し |
| 押し戻し | 落ちた行と調べ方を書き、答えは書かない | 先頭800文字が通ったテストで埋まる |
門を1つ塞ぐと隣の穴を通られます。それでも、門はハーネスが自分で閉じる以外にありません。モデルの「完了しました」を信用した瞬間に、第1回の「次はどうしますか?」と同じ場所に戻ります。
次回は、門を通っても「進んでいない」ランの話です。300ターンのうち7割以上がツールを1度も呼んでいなかったという数字から、空転を数える指標と、思考を切らずに上限だけ置く理由を書きます。
それでは、また次回、お会いしましょう!