画面の見た目、生成した画像、実機の挙動。目視でしか確認できない作業をAIエージェントに任せると、「完了しました」と返ってくるのに実際はできていない、ということが起きます。
原因を追っていくと、多くの場合たどり着くのは同じ場所でした。エージェントが自分の作業結果を見る手段を持っていない、という点です。見られないので、確認しようがない。結果として「たぶんできた」を報告することになります。
逆に言えば、見られるようにすれば自律作業の対象にできます。この記事はその実装の話です。
自社で4ヶ月ぶん(対策58件)の運用ログを追跡して、どの対策が効いてどれが効かなかったかを調べた結果は本家記事にまとめました。
AIエージェントの自律作業は「感覚器」で決まる。効果的なテクニック4選
この記事では、そのうちエージェントに結果を観測させる部分の実装だけを、動いているコードで説明します。数字や分析は本家に譲ります。
前提:テストが書ける領域では、この話は要りません
先に適用範囲をはっきりさせておきます。
- ユニットテストが書ける → テストを通す。それで十分です
- 型チェック・Lintで検出できる → CIに任せる
問題になるのは、テストで判定できないものです。
- 画面の見た目が崩れていないか
- 生成した画像が意図どおりか
- 実機で操作したときに正しく動くか
ここには「通る/通らない」がありません。だから人間が見るまで判定が保留になり、エージェントは止まります。
やることは1つで、エージェントが読める形式で結果を出力し、それを自分で読ませる。図にするとこうなります。
大事なのは「エージェント自身が読む」の矢印が存在するかだけです。ここが人間に繋がっていると、人間が見るまで待つことになるので自律できません。
以下は領域別の実装です。
1. Godot:起動オプションでスクリーンショットを撮らせる
ゲーム画面が意図どおりに描画されているかは、テストで書けません。そこで起動時にスクショを保存して終了する起動オプションを実装します。
# main.gd
func _capture_and_quit() -> void:
for i in range(6):
await get_tree().process_frame
await get_tree().create_timer(0.3).timeout
var img := get_viewport().get_texture().get_image()
var out_path := ProjectSettings.globalize_path("res://") + "shot.png"
var err := img.save_png(out_path)
print("[shot] saved=", out_path, " err=", err)
get_tree().quit()
呼び出し側でコマンドライン引数を見て分岐します。
var args := OS.get_cmdline_user_args()
if "--shot" in args:
await _capture_and_quit()
これでエージェントはこう動けます。
godot --path . -- --shot
shot.png が生成されるので、エージェントはそれを自分で読んで判断します。崩れていれば直して撮り直す。人間の指摘を待つ必要がありません。
罠:描画完了前に撮ると真っ黒になる
上のコードで await get_tree().process_frame を6フレーム回してから create_timer(0.3) を待っているのは、飾りではありません。
描画が完了する前にキャプチャすると真っ黒な画像が保存されます。 そして厄介なことに、エージェントはそれを「表示が壊れている」と誤認します。存在しないバグを直そうとして、無駄な修正ループに入ります。
感覚器を作るときは、正しく観測できる状態まで待つ処理をセットで入れてください。ここを省くと、感覚器が嘘をつく装置になります。
シーン単位のプレビューも用意しておく
ゲーム全体ではなく、特定のアセットだけ確認したいことがあります。
godot --path . preview_mesh.tscn -- --shot --glb res://assets/characters/yokai/oni.glb
3Dモデルを1体だけ表示して撮る、という専用シーンです。エージェントが「取り込んだモデルの向きが正しいか」「原点がずれていないか」を単独で確認できます。
観測の粒度を細かくできると、問題の切り分けもエージェント側でできるようになります。
2. HTML:ヘッドレスブラウザで撮って自分で読ませる
Webページやレポートを生成したら、必ずヘッドレスでキャプチャして、その画像をエージェント自身に読ませます。
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--headless --disable-gpu --no-sandbox \
--screenshot=/tmp/preview.png \
--window-size=1280,3000 \
--hide-scrollbars \
"http://localhost:3001/news/your-page"
--hide-scrollbars を入れておくとスクロールバーが写り込まず、差分比較がしやすくなります。
長いページは分割キャプチャする
Chrome CLI の --screenshot は指定したビューポート分しか撮れません。記事のような縦長ページだと途中で切れます。
実際、この記事の元になった本家記事は縦 14,941px ありました。--window-size=1280,7000 で撮ると、半分弱しか写っていないのに「確認した」と言えてしまいます。
そこで Puppeteer でスクロールしながら分割で撮ります。以下は今回の確認で実際に使ったスクリプトです。
// shots.js
const puppeteer = require('puppeteer');
const URL = process.argv[2];
const OUT = process.argv[3];
const H = 2600; // 1枚あたりの高さ
(async () => {
const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: H });
await page.goto(URL, { waitUntil: 'networkidle2', timeout: 60000 });
const total = await page.evaluate(() => document.body.scrollHeight);
const n = Math.ceil(total / H);
console.log(`total=${total}px / ${n}枚に分割`);
for (let i = 0; i < n; i++) {
const y = i * H;
await page.evaluate((y) => window.scrollTo(0, y), y);
await new Promise(r => setTimeout(r, 400)); // 遅延ロード対策
await page.screenshot({ path: `${OUT}/p${String(i + 1).padStart(2, '0')}.png` });
console.log(`p${String(i + 1).padStart(2, '0')}.png (y=${y})`);
}
await browser.close();
})();
node shots.js "https://example.com/article" ./shots
# total=14941px / 6枚に分割
# p01.png (y=0)
# ...
ポイントは2つです。
-
document.body.scrollHeightを実測して枚数を決める。決め打ちにすると、ページが伸びたときに末尾を見落とします - スクロール後に少し待つ。遅延ロードの画像が間に合わず、白い箱だけ撮れることがあります
出力に total=14941px / 6枚に分割 と実際の枚数を印字させるのも意図的です。エージェントが「全部見た」と言うときの根拠になり、報告と実際のズレを検出できます。
一番大事なのは「何を確認とみなすか」
ここが実装の本題です。やりがちな失敗を並べます。
| やったこと | 報告 | なぜ確認になっていないか |
|---|---|---|
ls でファイルの存在を確認 |
「生成できました」 | 中身が壊れていても ls は成功する |
| HTTP 200 が返った | 「表示を確認しました」 | 200 は表示の正しさを保証しない |
showSaveFilePicker が関数として存在する |
「実機確認済み」 | 存在と動作は別物 |
file:// で開いて確認 |
「表示に問題なし」 | HTTP配信時の文字化けを見逃す |
| ビルドが成功した | 「記事を公開しました」 | ビルド成功はそのページの生成を保証しない |
判断基準は1行で書けます。
その操作は、壊れていたときに失敗するか?
ls は壊れていても成功します。HTTP 200 も返ります。だから確認になりません。
最後の「ビルド成功」は特に見落としやすい項目です。ビルドが通っても、出力先のパスを取り違えていれば成果物を確認したことにはなりません。正しい検証はこうです。
# ビルド成功ではなく、成果物の中身を見る
grep -c "記事タイトル" out/news/your-slug.html # 本文に入っているか
grep -c "your-slug" out/news.html # 一覧に載っているか
curl -s -o /dev/null -w "%{http_code} %{size_download}\n" https://example.com/news/your-slug
配信サイズがビルド出力のサイズと一致するかまで見ると、キャッシュ由来の古いファイルを掴んでいないかも分かります。
3. 判定できる部分はシェルに落とし、LLMは解釈だけに使う
自律で走らせる処理では、判定を LLM にやらせないのが安定します。
自社のセキュリティ診断は、検査項目を1つずつシェルスクリプトにしています。
runner/
├── run.sh
├── targets.sh # 診断対象の定義
└── checks/
├── d01_exposure.sh # 公開範囲
├── d03_devmode.sh # 開発モードの残存
├── d04_auth_boundary.sh # 認証境界
├── d05_headers.sh # セキュリティヘッダ
├── d11_image_cve.sh # イメージのCVE
└── d13_container.sh # コンテナ設定
./run.sh --target ops
./run.sh --target c3 --checks D-04,D-11
出力は results/<timestamp>/<target>/D-XX.json という構造化データで残します。これが感覚器です。
自然言語のログを読ませると解釈がぶれますが、JSONなら「どのキーが true か」で判定が決まります。エージェントの仕事は判定ではなく、判定結果の解釈と次の手の提案になります。
安全側の設計も入れています。
- 対象は自社所有の稼働環境のみ(
targets.shで定義) - read-only / 受動的な検査のみ。能動スキャンはしない
- 検出結果はそのまま適用しない。承認キューに積む
検出と修正を同じループに入れると、誤検知がそのまま本番に反映されます。自律化するのは検出までにしておくのが無難です。
4. 実装するときのチェックリスト
新しい領域でエージェントを自走させたいとき、私は次の順で考えています。
- その作業の成否は、テストで判定できるか? → できるならテストを書いて終わり
- できないなら、結果をエージェントが読める形式で出せるか?(画像 / JSON / テキストログ)
- その出力は、壊れていたときにちゃんと壊れて見えるか?(真っ黒画像・空JSONに注意)
- 観測してから判定するまでが1つのコマンドで完結するか?(人間の介在が挟まらないか)
- 出力に実測値を印字しているか?(枚数・件数・サイズ。報告と実際のズレを検出できる)
GUIしか出力がないツールは、この時点で自律作業に向きません。そこは素直に人間が見る領域として残したほうがいいです。
おわりに
エージェントを自律させようとすると、ついプロンプトの書き方を工夫しがちです。実際に効果を追跡してみると、そちらはほとんど効きませんでした。効いたのは、この記事で書いたような観測経路を作る側の作業でした。
判断に使える問いは1つです。
そのタスクで、エージェントは自分の作業結果を見られますか?
見られないなら、どれだけプロンプトを工夫しても自律にはなりません。逆にここさえ通れば、指示文はそれほど凝らなくても回り始めます。
対策58件を追跡した結果(何が効いて何が効かなかったか、実測値つき)は本家に書いています。あわせてどうぞ。

