ゼロから作るコーディングエージェント【第2回】完了は「作った」ではなく「動いた」で決める。完了の門と、押し戻しに書くこと

モデルの「完了しました」は5つの形ですり抜けました。構文検査だけ、テストは通るが動かない、待ち受けは立つが最初の要求で落ちる、起動するが仕様のAPIが無い、assertの無いテスト。完了の門を3段で閉じる設計と、押し戻しに「落ちた行と調べ方」を書いて「答え」を書かない理由を、数字つきで書きます。

ゼロから作るコーディングエージェント【第2回】完了は「作った」ではなく「動いた」で決める。完了の門と、押し戻しに書くこと

こんにちは!

自作のコーディングエージェントに、認証つきのタスク管理Webを作らせていたときの話です。モデルは40ターンで「すべての機能を実装し、テストも通りました。完了です」と報告して止まりました。テストの出力を見ると、確かに全部通っています。

ところが、そのサーバを起動して仕様書どおりのAPIを叩いてみると、22項目の確認のうち通ったのは2項目でした。仕様書のAPIは12本で、それを正常系と異常系に分けた採点器の確認項目が22個です。

テストは通っている。サーバも起動する。それでも仕様の1割も動いていない。モデルが書いたテストが、仕様ではなく自分の実装を写していたからです。

結論から言うと、コーディングエージェントの「完了」はモデルの宣言ではなく、作ったものを実際に動かした証拠で決める必要があります。そして、その証拠を集める係はモデルではなく、エージェント側(ハーネス)が持たなければなりません。弊社ではこれを「完了の門」と呼んでいて、105件直した欠陥のうち、いちばん多くの欠陥がこの門の周りで出ました。

図1 完了の門は3段で閉じる(作図: Qualiteg)
図1 完了の門は3段で閉じる(作図: Qualiteg)

この記事は連載「ゼロから作るコーディングエージェント」の第2回です。前回は「ターンを回す係」と「止めてよいか決める係」を分ける話を書きました。今回はその「止めてよいか決める係」の中でいちばん重い判定、完了の門の話です。

回テーマ
第1回「最後までやりきる」が難しい理由。ターンを回す係と止める係を分ける
第2回(今回)完了は「作った」ではなく「動いた」で決める。完了の門と押し戻しの書き方
第3回「止まらなかった」は「進んだ」ではない。空転を数え、思考は切らずに上限だけ置く
第4回自走の規律は人がいない場面のもの。人がいるなら止まって聞く
第5回ローカルLLMを一級市民にする。16GB×2でWebシステムを作らせるまで
第6回評価をどう作るか。動くものを作らせて自動採点し、採点器そのものを疑う
第7回記録と回帰。全部イベントログから見つかる。そして依存0で作る

1. 「完了しました」がすり抜けた5つの形

門を作る前に、何がすり抜けたかを並べます。全部、弊社の手元で実際に起きたことです。

図2 完了をすり抜けた5つの形(作図: Qualiteg)
図2 完了をすり抜けた5つの形(作図: Qualiteg)
すり抜けた形実際に起きたこと
構文検査だけで「動作確認した」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. 押し戻しに書くこと。書かないこと

門で止めたら、モデルに「なぜ通らないか」を返します。この文面の書き方で、その後のターン数が大きく変わりました。

図3 押し戻しに書くこと、書かないこと(作図: Qualiteg)
図3 押し戻しに書くこと、書かないこと(作図: Qualiteg)

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度も呼んでいなかったという数字から、空転を数える指標と、思考を切らずに上限だけ置く理由を書きます。

それでは、また次回、お会いしましょう!

関連記事

Read more

「Blackwell」は 1 つではなかった。NVIDIA の Compute Capability 番号のわかりにくさを整理する

「Blackwell」は 1 つではなかった。NVIDIA の Compute Capability 番号のわかりにくさを整理する

こんにちは! NVIDIA の GPU を扱っていると、sm_120 や sm_100 といった番号を見かけます。Compute Capability と呼ばれる番号です。 この番号、製品の世代名と対応しているように見えて、実はずれています。 きっかけは、2026年9月23日に公開された TensorRT-LLM の v1.3.0rc28 でした。リリースノートに「SM107」という記述があります。次世代の Rubin に対応した、という文脈です。 ここで引っかかります。Blackwell の GeForce は sm_120 です。次の世代の Rubin が、なぜ 107 という小さい番号なのでしょうか。 調べてみると、引っかかりの原因は Rubin ではありませんでした。

By Qualiteg プロダクト開発部