AGENTS.md の一文を「なるべく既存の関数を再利用してください」から「既存の関数を優先的に再利用してください」に書き換えただけの日でした。意図は同じはずです。ところが翌朝の無人ランで、あるタスクだけが新しいユーティリティを毎回書き下ろすようになっていました。コードは一行も触っていません。壊れたのは指示の方です。
個人開発で Antigravity のエージェントに定期作業を預けていると、この種の事故がいちばん怖いと感じています。テストが赤くなるわけでもなく、例外が飛ぶわけでもない。ただ、以前できていたことが静かにできなくなる。しかも原因は自分が善意で直した指示文なので、気づくまでに時間がかかります。
コードには回帰テストを書くのに、なぜ指示文には書かないのか。ここ数週間、その問いに実装で答えを出そうとしてきました。被験体をコードではなく「エージェントに渡す指示」に置いた回帰ハーネスの話です。
指示は暗黙のコードだと考える
エージェントの振る舞いは、モデルの重みと、こちらが渡す指示文の合成で決まります。モデルは自分では変えられませんが、指示文はこちらが日々書き換えています。つまり AGENTS.md やスキル文は、実行時に解釈される一種のソースコードです。ソースコードなら、変更したときに退行していないかを測る仕組みがあってしかるべきです。
ただし普通の単体テストは使えません。関数の戻り値のように、同じ入力から同じ出力が返るとは限らないからです。同じ指示・同じタスクでも、エージェントの出力は毎回少しずつ違います。だから被験体を「出力そのもの」ではなく「出力が満たすべき性質」に置き換えます。性質さえ固定できれば、多少ゆらいでも合否は判定できます。
この発想は完了判定を外部化する考え方と地続きです。エージェントの自己申告を信じず、外から検証する設計は完了の自己申告から卒業させる完了契約の記事 で扱いました。本稿はその一歩手前、「指示を変えた瞬間に、複数タスクをまとめて検証し直す」層を作ります。
fixture の粒度を先に決める
回帰スイートの中身は fixture の集合です。1 つの fixture は「タスクの指示」と「そのタスクが満たすべきアサーション」の組で表します。ここで粒度を欲張ると破綻します。私は最初、実際の業務タスクをそのまま fixture にしようとして失敗しました。1 ランに数分かかり、外部 API を叩き、結果が環境に依存する。回帰スイートとしては重すぎたのです。本番運用のタスクをそのまま持ち込むのは、この仕組みの最初の落とし穴でした。この重さを回避するために、いまは代表的で軽いタスクに絞っています。
いまは fixture を「代表的で、短く、副作用が閉じている」タスクに絞っています。目安は 1 fixture あたり 30 秒以内、外部ネットワークに出ない、生成物はサンドボックス内のファイルだけ。実業務そのものではなく、実業務で守ってほしい性質を最小の形で再現したものを置きます。
アサーション種別 判定するもの 揺らぎへの強さ
ファイル存在 期待する成果物が生成されたか 強い
grep パターン 特定の関数・API を使ったか 中
終了コード 生成物がそのまま実行・ビルドできるか 強い
禁止語 やめてほしい書き方をしていないか 強い
意味的な正しさ(コードの意図が合っているか)まで機械では測りません。そこはモデル審査に頼ると揺らぎが増え、回帰検知の土台が崩れます。決定的に測れる性質だけをアサーションに置くのが、回帰スイートを信用できるものにする鍵でした。
fixture を宣言的に書く
fixture は 1 タスク 1 ファイルの YAML で持ちます。指示・準備コマンド・アサーションを 1 か所にまとめ、あとはハーネスがこれを解釈して回します。
# fixtures/reuse-existing-util.yaml
id : reuse-existing-util
prompt : |
src/utils.py に slugify があります。
記事タイトルから slug を作る処理を追加してください。
setup : |
mkdir -p src
printf 'def slugify(s):\n return s.lower().replace(" ", "-")\n' > src/utils.py
assertions :
- type : file_exists
path : src/utils.py
- type : grep
path : src
pattern : "from .utils import slugify|utils.slugify|slugify \\ ("
must : present
- type : grep
path : src
pattern : "def slugify"
count_max : 1 # slugify を再定義していないこと
- type : forbidden
path : src
pattern : "re.sub" # 既存を無視して正規表現で書き直していないか
宣言的にしておくと、指示文を直したあとで fixture を足すのが苦になりません。「この挙動を守りたい」と思った瞬間に、YAML を 1 枚書けばスイートに加わります。回帰スイートは、育てるコストが低いほど続きます。
ハーネス本体を実装する
ハーネスの仕事は単純です。fixture ごとに一時ディレクトリを作り、setup を流し、エージェントを走らせ、アサーションを採点する。ここではエージェント起動を run_agent に抽象化しておき、実際には Antigravity CLI をヘッドレスで呼ぶ差し込み口にします。
# harness.py
import subprocess, tempfile, shutil, glob, re, os, sys, json
from pathlib import Path
import yaml
def run_agent (workdir: str , prompt: str ) -> int :
"""Antigravity CLI をヘッドレスで実行する差し込み口。
実運用では下の1行を CLI 呼び出しに置き換える。"""
cmd = [ "antigravity" , "run" , "--headless" , "--yes" , "-p" , prompt]
proc = subprocess.run(cmd, cwd = workdir, capture_output = True , text = True , timeout = 120 )
return proc.returncode
def _files (base: str , path: str ):
root = os.path.join(base, path)
if os.path.isfile(root):
return [root]
return [p for p in glob.glob(os.path.join(root, "**" , "*" ), recursive = True )
if os.path.isfile(p)]
def check (base: str , a: dict ) -> bool :
t = a[ "type" ]
if t == "file_exists" :
return os.path.isfile(os.path.join(base, a[ "path" ]))
if t in ( "grep" , "forbidden" ):
pat = re.compile(a[ "pattern" ])
hits = 0
for f in _files(base, a[ "path" ]):
try :
hits += len (pat.findall(Path(f).read_text( errors = "ignore" )))
except OSError :
pass
if t == "forbidden" :
return hits == 0
if a.get( "must" ) == "present" and hits == 0 :
return False
if "count_max" in a and hits > a[ "count_max" ]:
return False
return True
if t == "exit_zero" :
r = subprocess.run(a[ "cmd" ], cwd = base, shell = True , capture_output = True )
return r.returncode == 0
raise ValueError ( f "unknown assertion: { t } " )
def run_fixture (fx: dict , trials: int ) -> dict :
passes = 0
for _ in range (trials):
base = tempfile.mkdtemp( prefix = "fx-" )
try :
if fx.get( "setup" ):
subprocess.run(fx[ "setup" ], cwd = base, shell = True , check = False )
run_agent(base, fx[ "prompt" ])
ok = all (check(base, a) for a in fx[ "assertions" ])
passes += 1 if ok else 0
finally :
shutil.rmtree(base, ignore_errors = True )
return { "id" : fx[ "id" ], "passes" : passes, "trials" : trials,
"score" : round (passes / trials, 3 )}
if __name__ == "__main__" :
trials = int (os.environ.get( "TRIALS" , "3" ))
results = []
for path in sorted (glob.glob( "fixtures/*.yaml" )):
fx = yaml.safe_load(Path(path).read_text())
results.append(run_fixture(fx, trials))
json.dump({ "results" : results}, sys.stdout, ensure_ascii = False , indent = 2 )
check は決定的なアサーションだけを扱うので、同じサンドボックスに対しては必ず同じ結果を返します。揺らぎは run_agent の側にしかありません。だから多試行にして、揺らぎをスコアに畳み込みます。
退行はベースラインとの差分で捕まえる
1 回のスコアだけでは「昨日より悪いか」が分かりません。基準となるスコアを保存し、指示文を変えた後のスコアと引き算します。合否は絶対値ではなく、下落幅で判定するのが実務的でした。
# compare.py — 変更前後のスコアを比較して退行を検知する
import json, sys
def load (p):
return {r[ "id" ]: r[ "score" ] for r in json.load( open (p))[ "results" ]}
base = load(sys.argv[ 1 ]) # baseline.json(変更前)
head = load(sys.argv[ 2 ]) # current.json(変更後)
TOL = 0.34 # 3試行なら1回落ちる程度は許容
regressed = []
for fid, b in base.items():
h = head.get(fid, 0.0 )
if b - h > TOL :
regressed.append((fid, b, h))
for fid, b, h in regressed:
print ( f "REGRESSION { fid } : { b :.2f } -> { h :.2f } " )
sys.exit( 1 if regressed else 0 )
許容幅 TOL は試行回数と相談して決めます。3 試行なら、たまたま 1 回落ちるのは揺らぎの範囲です。ここを 0 にすると、指示は健全なのにスイートが赤くなり続け、やがて誰も見なくなります。揺らぎと退行を切り分ける設計思想は評価ゲートが揺れて信用できないときの記事 と同じで、合否の安定こそがこの手のゲートの生命線です。
実際に回して分かったこと
6 週間ほど、fixture 11 本・3 試行の構成で AGENTS.md を触るたびに回してきました。1 ラン合計はおおむね 4〜6 分です。この間に本物の退行を 2 件捕まえました。1 件は冒頭の「優先的に再利用」への書き換えで、再利用を促す語を強めたつもりが、逆に「新規実装も許容される」と読める余地を広げていました。もう 1 件は禁止語リストを整理した際に、意図せず既存の禁止項目を 1 行落としていた事故です。どちらもテストは緑、例外もゼロで、ハーネスがなければ数日は気づけなかったと思います。
一方で誤検知も 4 件ありました。すべて TOL を厳しくしていた初期の話で、揺らぎを退行と読み違えたものです。試行を 3 回に、許容を 1 回分に緩めてからは、誤検知はほぼ消えました。回帰スイートは「本物の退行を逃さない」ことと同じくらい、「揺らぎで狼少年にならない」ことが大事だと痛感しています。試行を 1 回から 3 回に増やすと 1 ランのコストはおよそ 3 倍になりますが、私はこの重さを払う価値があると考えています。個人的には、試行 3 回・許容 1 回分を既定にすることを勧めています。
無人スケジュールに組み込む
このハーネスの真価は、指示を変えた瞬間だけでなく、定期実行の中で効いてきます。私は AGENTS.md やスキル文を含むリポジトリへの push をトリガーに、ベースラインとの比較まで一気に流しています。
#!/usr/bin/env bash
# ci-regression.sh — 指示文の変更で退行が出たら止める
set -euo pipefail
TRIALS = 3 python harness.py > current.json
if [ ! -f baseline.json ]; then
cp current.json baseline.json # 初回はそのまま基準にする
echo "baseline を作成しました" ; exit 0
fi
python compare.py baseline.json current.json
無人運用では、退行を検知したら「その指示変更を採用しない」で止めるのが安全です。ここで OAuth の再認証プロンプトが挟まると自動フローが途切れるので、トークンをキーリングに保持する設定は前提になります。無人ランの認証まわりはOAuth トークンをキーリングに保存する記事 にまとめました。
ベースラインの更新は手動にしています。スコアが下がったのが退行ではなく「新しい望ましい挙動」だと確認できたときだけ、baseline.json を差し替える。ここを自動化すると、静かな劣化を基準ごと飲み込んでしまうためです。
次の一歩
最初の一往復は、次の 3 手で試せます。
守りたい挙動を 1 つ選び、最小の fixture を 1 枚だけ書く
指示文を 1 か所わざと壊して、ハーネスを 3 試行で回す
スコアが下がることを確認し、ベースラインとの差分を取る
この「壊して下がるのを見る」一往復が通れば、指示はもう暗黙のコードではなく、変更を測れる対象になります。
私自身、指示文の管理はまだ手探りの途中です。それでも、善意の一行修正で静かに壊す事故が減っただけで、無人でエージェントに任せる夜の安心感は確かに変わりました。お読みいただきありがとうございました。共に少しずつ確かめていけたら嬉しいです。