Antigravity に「この決済モジュールを新しい SDK に載せ替えてほしい」と頼み、戻ってきたら「完了しました。ビルドもテストも通っています」と書かれていました。差分を見ると確かに新 SDK の呼び出しに置き換わっていて、ユニットテストも緑です。ところがプロダクション環境に近い構成でリトライ込みの結線テストを回すと、特定のタイムアウト経路だけ旧 SDK の例外型を握り続けていて、リカバリが動いていませんでした。
エージェントが嘘をついたわけではありません。エージェントの「完了」と私の「完了」が、最初から別物だっただけです。ここでは、完了の定義をエージェントの外側に固定する「完了契約」という設計と、それを担保する自動検証の組み方を、個人開発で実際に運用しているコードまで含めて整理します。スケジュール実行や無人エージェントが当たり前になった 2026 年では、この設計の有無が運用コストを直接左右するようになりました。
なぜ「完了しました」は構造的にズレるのか
完了をエージェントに自己判断させると、3 つの層が静かに混ざります。形式的な完了(指示どおりファイルが変わった・関数が増えた)、機能的な完了(実行結果が期待どおり動く)、意図的な完了(依頼の本当の目的が満たされ、他を壊していない)。指示が曖昧なとき、エージェントは最も早く到達できる形式的完了で手を止めます。ビルドが通った、テストが緑になった、プロンプトの箇条書きが埋まった——そこが停止点になります。
厄介なのは、停止点がエージェント側にあること自体です。能力が上がっても、完了の物差しを相手に預けている限り、物差しのズレは構造的に残ります。解決の方向は単純で、完了の宣言権をエージェントから取り上げ、外部の検証器だけが「完了」を出せるようにします。エージェントの仕事は「検証器を緑にすること」へ変わり、検証器を書くのは依頼者である私です。
完了契約(completion contract)を先に固定する
私はタスクごとに、何をもって完了とするかを機械可読な「契約」として先に書きます。重要なのは、タスクを始める前に、同じコミットで契約を入れることです。着手後に書くと、エージェントが出した差分に合わせて契約が甘くなる逆流が必ず起きます。
# tasks/payment-sdk-migration/contract.yaml
task_id: payment-sdk-migration
# 完了の宣言権はこのファイルにしかない
formal:
- "pnpm lint"
- "pnpm tsc --noEmit"
- "! grep -rn 'legacy-pay' src --include='*.ts'" # 旧SDK名の残留禁止
functional:
- "pnpm test src/payment"
- "pnpm test:contract -- --grep 'timeout-recovery'" # 落ちていた経路を必須化
intent:
questions:
- id: side_effects
ask: "決済以外の画面で旧SDKの型に依存していた箇所はどこか。どのファイルで確認したか"
- id: error_paths
ask: "タイムアウト・5xx・ネットワーク断の3経路で、リカバリが動くことをどのテストで確認したか"
- id: untouched
ask: "触れるべきでなかったのに変更したファイルはあるか。あれば全て列挙"
budget:
max_files_changed: 18 # これを超えたら意図逸脱として要レビュー
forbid_paths: ["infra/", "src/auth/"] # 触らせない境界
契約をタスク冒頭でエージェントに渡すとき、停止条件を一文で釘を刺します。「このタスクは scripts/done_check.py が終了コード 0 を返した時点でのみ完了。途中でビルドや lint が通っても、done_check が通るまで完了と報告しないこと。意図質問は tasks/<id>/intent.md に 1 問ずつ回答を書く」。この一文があるだけで、「完了しました」の意味が固定されます。
検証器を 3 層で実装する
契約を実際に判定するのが done_check.py です。形式 → 機能 → 意図の順に、浅く速い層から落としていきます。終了コードは「0=完了、2=形式/機能の不合格、3=意図の不合格、4=予算(変更範囲)逸脱」と分け、後段の自動処理が理由で分岐できるようにしています。
# scripts/done_check.py
import subprocess, sys, yaml, json
from pathlib import Path
EXIT_OK, EXIT_CHECK, EXIT_INTENT, EXIT_BUDGET = 0, 2, 3, 4
def sh(cmd: str) -> int:
print(f"::group::{cmd}")
rc = subprocess.run(cmd, shell=True).returncode
print("::endgroup::")
return rc
def changed_files() -> list[str]:
out = subprocess.check_output(
"git diff --name-only origin/main...HEAD", shell=True, text=True)
return [l for l in out.splitlines() if l.strip()]
def main(contract_path: str) -> int:
c = yaml.safe_load(Path(contract_path).read_text())
task_dir = Path(contract_path).parent
# 予算(変更範囲)を先に見る。逸脱は意図逸脱の強いシグナル
files = changed_files()
budget = c.get("budget", {})
if (m := budget.get("max_files_changed")) and len(files) > m:
print(f"BUDGET: {len(files)} files > {m}")
return EXIT_BUDGET
for forbidden in budget.get("forbid_paths", []):
if any(f.startswith(forbidden) for f in files):
print(f"BUDGET: touched forbidden path {forbidden}")
return EXIT_BUDGET
# 形式・機能。1つでも落ちたら即終了(後段の意図検証に進ませない)
for layer, exit_code in (("formal", EXIT_CHECK), ("functional", EXIT_CHECK)):
for cmd in c.get(layer, []):
if sh(cmd) != 0:
print(f"{layer.upper()} FAILED: {cmd}")
return exit_code
# 意図。質問への回答が「存在し・具体的か」を機械的にふるいにかける
intent = c.get("intent", {})
answers = (task_dir / "intent.md")
if intent.get("questions"):
if not answers.exists():
print("INTENT: intent.md がありません")
return EXIT_INTENT
text = answers.read_text()
for q in intent["questions"]:
qid = q["id"]
block = extract_answer(text, qid)
if not block or len(block.strip()) < 40 or "確認しました" == block.strip():
print(f"INTENT: 回答が不十分: {qid}")
return EXIT_INTENT
# 参照先(ファイル/テスト名)が含まれているか
if "side_effects" in qid or "error_paths" in qid:
if not any(tok in block for tok in (".ts", ".py", "test", "spec")):
print(f"INTENT: 参照先の明示なし: {qid}")
return EXIT_INTENT
print("DONE: all layers passed")
return EXIT_OK
def extract_answer(md: str, qid: str) -> str:
# "## <qid>" 見出し直下のブロックを取り出す簡易パーサ
lines, buf, capture = md.splitlines(), [], False
for ln in lines:
if ln.startswith("## "):
capture = (qid in ln)
continue
if capture:
buf.append(ln)
return "\n".join(buf)
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
ここで効いているのは、意図チェックを「回答の有無」ではなく「具体の有無」で落としている点です。長さの下限(40 字)と参照先トークン(.ts やテスト名)の要求だけで、「確認しました」一行の空回答はほぼ弾けます。完璧な自然言語判定は要りません。具体を書かせる過程で、エージェント自身の矛盾が表に出てくることを狙っています。
意図質問は「どう確認したか」を書かせる
意図チェックの質問は、設計でほぼ勝負が決まります。YES/NO で閉じる質問にしないことが第一です。「壊れていないか」ではなく「壊れていないことを、どのファイルのどのテストで確認したか」を聞きます。第二に、最後の質問を必ず「触れるべきでなかったのに変更したファイルはあるか」にします。これが一番効きます。差分を後から見て、変更があるのに「ありません」と書かれていたら、形式も機能も緑でも差し戻す——この習慣がレビューを軽くします。
回答ファイルは、面倒でも通読します。本文の正しさより、エージェントがタスクの本質をどう理解していたかが素直に出るからです。理解がズレていれば、たとえ全層が緑でも、次の似たタスクで同じ事故が起きます。
無人実行・スケジュール起動での完了検証
2026 年に入って、Antigravity を含む各社のエージェントはスケジュール起動や無人ループでの実行が標準になりました。Managed Agents の cron 起動や、Gemini 側の antigravity-preview-05-2026 のような隔離サンドボックス実行では、各ランを人間が見ていません。ここでこそ完了契約が効きます。見ている人がいないからこそ、完了の宣言権をコードに固定しておく必要があります。
無人運用で私自身が必ず入れているのは、次の 3 点です。
done_check.py の終了コードで分岐し、EXIT_BUDGET(変更範囲の逸脱)と EXIT_INTENT は自動マージを止めて隔離ブランチへ退避します。形式/機能の EXIT_CHECK だけは、エージェントに最大 2 回まで自己修正の再試行を許します。
- 再実行が無限ループに陥る落とし穴を回避するため、同一
task_id での連続失敗回数を状態ファイルに記録し、3 回で打ち切ってアラートだけ出します。
- 無人ランの完了は「検証器が緑」かつ「差分監査が緑」の二段で確定させます。片方だけでは完了にしません。
# scripts/unattended_gate.py(無人ランの最終関門・抜粋)
import subprocess, sys, json
from pathlib import Path
STATE = Path(".agent/attempts.json")
def load_state():
return json.loads(STATE.read_text()) if STATE.exists() else {}
def main(task_id: str, contract: str) -> int:
st = load_state()
rc = subprocess.run(["python", "scripts/done_check.py", contract]).returncode
if rc == 0:
# 差分監査:契約に列挙された forbid_paths 以外でも、巨大削除を検出
deleted = subprocess.check_output(
"git diff --numstat origin/main...HEAD", shell=True, text=True)
big_del = [l for l in deleted.splitlines()
if l and l.split('\t')[1].isdigit() and int(l.split('\t')[1]) > 400]
if big_del:
print("AUDIT: 400行超の削除を検出。隔離します")
return 5
st[task_id] = 0
STATE.write_text(json.dumps(st)); return 0
st[task_id] = st.get(task_id, 0) + 1
STATE.write_text(json.dumps(st))
if rc in (4,) or st[task_id] >= 3: # 予算逸脱 or 連続失敗で打ち切り
print(f"QUARANTINE: rc={rc} attempts={st[task_id]}")
return 9 # 自動マージせず人間に回す
print(f"RETRY: rc={rc} attempts={st[task_id]}")
return rc
if __name__ == "__main__":
sys.exit(main(sys.argv[1], sys.argv[2]))
退避先の隔離ブランチには、done_check のログと intent.md を一緒に残します。翌朝それだけ読めば、なぜ無人ランが止まったかが追えます。無人運用で一番怖いのは失敗そのものではなく、「失敗が静かに本番へ流れること」です。打ち切りとアラートを先に設計しておけば、無人でも安心して任せられます。
タスク規模で重さを変える
全タスクに 3 層を課すのは過剰です。1 ファイル以下の小さな変更は形式チェックだけで十分で、意図質問は省きます。複数ファイル・単一機能の中規模は形式+機能にして、そのタスク用のテストを先に書き下ろします。ディレクトリ単位の移行やクロスコンポーネントのリファクタなど大規模だけ 3 層すべてと予算・無人ゲートを乗せることを推奨します。私は意図チェックで 3 回続けて問題が出たタスクは、粒度が大きすぎる合図とみなして分割し直しています。
完了をエージェントに言わせない、と書くと大げさに聞こえますが、やっていることは「完了の定義を他人に預けない」という一点です。今日 Antigravity に何か渡すなら、そのタスク用の contract.yaml を一行でも先にコミットしてみてください。検証器を緑にする、という目標が共有された瞬間から、運用は静かに楽になります。
同じように無人でエージェントを回している方の運用が、少しでも静かに軽くなれば嬉しいです。