「カートに追加しました」と報告したのに、カートは空だった
夜間に走らせていた収集エージェントのログを朝に開くと、completed: true がずらりと並んでいました。けれど実際の成果物は半分しか揃っていない。ステップを追うと、あるサイトで「商品をカートに追加し、決済画面まで進みました」とエージェントが自信満々に報告しているのに、カートは最後まで空のままでした。クリックは座標の上では成功し、スクリーンショットには「追加」ボタンが押された風の見た目が写っている。それでも、肝心の状態は何も変わっていなかったのです。
これは Playwright のセレクタが壊れる、という昔ながらの故障とは質が違います。命令型スクリプトは壊れると例外を投げて止まってくれる ので、少なくとも気づけます。やっかいなのは、Claude にスクリーンショットを見せて次の操作を判断させる視覚エージェントが、壊れずに「成功したつもり」で先へ進む ことです。例外も出ず、ログも緑のまま、結果だけが間違っている。本番で一番こわいのはこの静かな失敗です。
ここから、エージェントの自己申告をいっさい信用しない前提で、各操作を目標に結びつけて事後検証する設計と、UI の変化に壊れる前に気づくドリフト検知を、動くコードとともに整理していきます。対象は TypeScript で Playwright と Claude API を触ったことがある方です。
なぜ視覚エージェントは「やったつもり」になるのか
命令型の Playwright スクリプトと、Claude にスクリーンショットを渡して判断させるエージェントでは、失敗の出方が根本的に違います。
観点 命令型スクリプト 視覚エージェント
UI 変更時の挙動 セレクタ不一致で例外 → 停止 見た目で推論し操作を続行
失敗の検知 例外で即わかる 静かに誤った状態へ進む
成功の判定根拠 明示的な waitForSelector モデルの「達成した」という自己申告
怖さ 止まるが気づける 止まらないが間違っている
誤成功が生まれる3つの経路
ここが最初の落とし穴です。誤成功(false success)が生まれる経路は、私が見てきた範囲では概ね3つに集約されます。第一に、クリック自体は座標上で成立したが、ボタンが無効化されていた・モーダルに覆われていた等で副作用が起きなかった 場合。第二に、スクリーンショットの撮影タイミングが非同期更新より早く、操作前の画面を見て「変わった」と誤認 する場合。第三に、モデルが目標達成を楽観的に宣言 する場合です。Claude は親切なので、ゴールに近い画面を見ると「達成できたと思います」と寄せてくることがあります。
共通する根っこは一つで、「操作した」ことと「状態が目的どおり変わった」ことを同一視している 点です。ここを切り離すのが設計の出発点になります。
自己申告を信用しない — 操作と検証を分離する
直すべき思い込みは、goalAchieved: true をモデルに言わせて信じる、という流れそのものです。エージェントの返答は「次にこう操作したい」という提案 までに役割を限定し、その操作が本当に効いたかは、モデルとは別の検証関数が観測可能な事実 で判定します。
// 操作の提案はモデル、合否は検証関数。役割をはっきり分ける
interface ActionProposal {
type : 'click' | 'fill' | 'navigate' ;
selector ?: string ;
value ?: string ;
url ?: string ;
// モデルが「この操作で何が起きるはず」と宣言する事後条件
expectedEffect : string ;
}
interface VerifiedStep {
proposal : ActionProposal ;
reasoning : string ;
verified : boolean ; // 検証関数の判定(モデルの自己申告ではない)
evidence : string ; // 合否の根拠(観測した事実)
}
ポイントは expectedEffect をモデル自身に宣言させることです。「追加ボタンを押す」だけでなく「押した後、カート件数が 1 増えるはず」まで言わせておくと、検証側はその予言が当たったかを照合できます。当たらなければ、操作は座標上で成功していても失敗として扱います 。
検証は「目標」に結びつける(goal-bound assertion)
検証で一番やってはいけないのは、汎用的な「成功っぽさ」を見にいくことです。.success-message が出たか、のような曖昧な確認は、別の理由で出た成功表示に引っかかって、また誤成功を生みます。検証は必ずそのステップの目標に固有の、状態を表す事実 へ結びつけます。
// src/agent/verify.ts
import { Page } from 'playwright' ;
interface VerificationResult {
verified : boolean ;
evidence : string ;
}
// 目標に固有の検証を、観測可能な事実で行う
export async function verifyCartAdded (
page : Page ,
expectedCount : number
) : Promise < VerificationResult > {
// 1. UI のテキストではなく、状態を持つ要素を狙う
const badge = page. locator ( '[data-testid="cart-count"]' );
// 2. 非同期更新を「待つ」。撮影タイミングのズレをここで吸収する
try {
await badge. waitFor ({ state: 'visible' , timeout: 5000 });
} catch {
return { verified: false , evidence: 'cart-count バッジが現れませんでした' };
}
const text = ( await badge. textContent ())?. trim () ?? '' ;
const actual = parseInt (text, 10 );
// 3. 期待した状態と一致するかで合否を出す
if (Number. isNaN (actual)) {
return { verified: false , evidence: `cart-count が数値ではありません: "${ text }"` };
}
return actual >= expectedCount
? { verified: true , evidence: `cart-count=${ actual }(期待 ${ expectedCount } 以上)` }
: { verified: false , evidence: `cart-count=${ actual }(期待 ${ expectedCount } に未達)` };
}
この検証はスクリーンショットを一切見ていません。意図的です。スクリーンショットは判断の入力には使うが、合否の根拠には使わない ——視覚は誤認するので、合否は DOM の状態や API のレスポンスといった、ごまかしの効かない事実で取ります。data-testid のような安定属性が無いサイトでは、見た目の文言ではなく、件数・URL・特定要素の有無・aria 属性など、できるだけ意味に紐づく観測点を選びます。
私は個人開発で Dolice Labs の4サイトを一人で運営していて、そのうち収集・整合性チェックの自動化を Claude と Playwright で回しているのですが、最初に痛い目を見たのがまさにここでした。スクリーンショットの「成功してそうな画面」を信じて検証を省いた結果、夜通し走ったジョブが翌朝には半分空振りしている。それ以来、各操作には必ず「状態で測れる事後条件」を一つ用意し、それが取れない操作はそもそもエージェントに任せない、という線引きをしています。視覚は道案内には優秀でも、検収係には向いていない、というのが実運用で得た実感です。
検証ゲートを実行ループに組み込む
操作・検証・再試行を一つのゲートにまとめます。検証に落ちたら、その失敗をモデルへ事実として差し戻し 、別アプローチを促すのが視覚エージェントの強みを活かす点です。命令型スクリプトには真似できません。
// src/agent/step.ts
import Anthropic from '@anthropic-ai/sdk' ;
import { Page } from 'playwright' ;
import { VerificationResult } from './verify' ;
type Verifier = ( page : Page ) => Promise < VerificationResult >;
export async function runVerifiedStep (
client : Anthropic ,
page : Page ,
proposal : ActionProposal ,
verify : Verifier ,
history : Anthropic . MessageParam []
) : Promise < VerifiedStep > {
// 1. 提案された操作を実行
await applyAction (page, proposal);
// 2. 目標に固有の検証(モデルの自己申告ではない)
const result = await verify (page);
// 3. 落ちたら、観測した事実をモデルに差し戻す
if ( ! result.verified) {
history. push ({
role: 'user' ,
content:
`直前の操作(${ proposal . type } ${ proposal . selector ?? ''})は` +
`期待した効果「${ proposal . expectedEffect }」を生みませんでした。` +
`観測した事実: ${ result . evidence }。別の方法を提案してください。` ,
});
}
return {
proposal,
reasoning: proposal.expectedEffect,
verified: result.verified,
evidence: result.evidence,
};
}
async function applyAction ( page : Page , a : ActionProposal ) : Promise < void > {
if (a.type === 'click' && a.selector) await page. click (a.selector, { timeout: 8000 });
else if (a.type === 'fill' && a.selector) await page. fill (a.selector, a.value ?? '' );
else if (a.type === 'navigate' && a.url) await page. goto (a.url, { waitUntil: 'networkidle' });
}
ゴール全体の完了も、同じ思想で判定します。最後のステップで verified: true が取れたかを完了条件にし、モデルが goalAchieved と言ったかどうかは完了判定に使いません 。自己申告は提案の一部として読むに留め、最終的な真偽は検証が握る、という非対称を徹底します。
構造化ツール呼び出しで検証点を明示させる
スクリーンショットだけでなく、ページの構造をツールとしてモデルに渡すと、操作の精度も検証の確度も上がります。ここで効くのは、操作ツールに加えて**「事後条件を観測するツール」**を渡し、モデルに自分で検証点を選ばせることです。
const tools : Anthropic . Tool [] = [
{
name: 'execute_action' ,
description: 'ブラウザ操作を実行する。expectedEffect に状態で測れる事後条件を必ず書く' ,
input_schema: {
type: 'object' ,
properties: {
type: { type: 'string' , enum: [ 'click' , 'fill' , 'navigate' ] },
selector: { type: 'string' },
value: { type: 'string' },
expectedEffect: {
type: 'string' ,
description: '例: "cart-count が 1 増える"(見た目ではなく状態で書く)' ,
},
},
required: [ 'type' , 'expectedEffect' ],
},
},
{
name: 'observe_state' ,
description: '事後条件の確認に使う観測(要素の件数・テキスト・存在・現在URL)' ,
input_schema: {
type: 'object' ,
properties: {
selector: { type: 'string' },
property: { type: 'string' , enum: [ 'count' , 'text' , 'exists' , 'url' ] },
},
required: [ 'property' ],
},
},
];
const response = await client.messages. create ({
model: 'claude-sonnet-4-6' ,
max_tokens: 1536 ,
tools,
messages: history,
});
observe_state を独立したツールにしておくと、検証が「決め打ちのコード」ではなく、画面ごとにモデルが選んだ観測点で行えます。決め打ちの verifyCartAdded のような関数は安定サイト向け、observe_state 経由の動的検証は構造が読みにくいサイト向け、と使い分けると現実的です。
システムプロンプトはキャッシュして検証の余力を確保する
各ステップで同じルールを送り直すのは無駄ですし、検証を二重化するとトークンも増えます。長いシステムプロンプトは cache_control でキャッシュし、浮いたコストを検証の往復に回します。
const system : Anthropic . TextBlockParam [] = [
{
type: 'text' ,
text:
'あなたはブラウザを操作するエージェントです。' +
'各操作には状態で測れる expectedEffect を必ず添え、' +
'検証に落ちたら同じ操作を繰り返さず別経路を試してください。' +
'目標達成を自己申告で断定せず、観測された事実に委ねてください。' ,
cache_control: { type: 'ephemeral' }, // 現行 SDK は messages.create に直接指定できる
},
];
const response = await client.messages. create ({
model: 'claude-sonnet-4-6' ,
max_tokens: 1536 ,
system,
messages: recentHistory,
});
旧来の client.beta.promptCaching.messages.create を使った記事も多いですが、現行 SDK では通常の messages.create に cache_control を直接書けます。古い分岐が残っている場合はここで一掃しておくと保守が楽になります。
UI ドリフトに「壊れる前」に気づく
軽量カナリアで検証点の生存を確かめる
誤成功の温床は UI の変化です。だからこそ、本番ジョブの中で初めて気づくのではなく、変化そのものを定期的に観測 して先回りします。私が置いているのは、主要画面の「検証点が今も存在するか」を確かめるだけの軽量カナリアです。
// src/monitor/drift.ts
import { chromium } from 'playwright' ;
interface Anchor { url : string ; selector : string ; label : string ; }
// 各サイトの「検証に使っている観測点」をアンカーとして登録
const ANCHORS : Anchor [] = [
{ url: 'https://example.com/product/1' , selector: '[data-testid="add-to-cart"]' , label: '追加ボタン' },
{ url: 'https://example.com/cart' , selector: '[data-testid="cart-count"]' , label: 'カート件数バッジ' },
];
export async function checkDrift () : Promise <{ ok : boolean ; missing : string [] }> {
const browser = await chromium. launch ({ headless: true });
const page = await browser. newPage ();
const missing : string [] = [];
for ( const a of ANCHORS ) {
await page. goto (a.url, { waitUntil: 'domcontentloaded' });
const exists = ( await page. locator (a.selector). count ()) > 0 ;
if ( ! exists) missing. push ( `${ a . label }(${ a . selector })` );
}
await browser. close ();
return { ok: missing. length === 0 , missing };
}
このカナリアを本番ループとは別に1日数回走らせ、アンカーが消えていたら収集ジョブを起動する前に 通知します。検証点が消えているということは、まさに誤成功が起きる条件が整ったということです。エージェントに任せて静かに失敗させるより、人間が5分でセレクタを更新するほうが、結局は安いというのが私の判断です。
誤成功率を測り、サーキットブレーカーで止める
しきい値の目安と止め方
最後は運用の安全弁です。誤成功をその場で解決しようとせず、まず走り続けるのを止めて被害を回避する、という発想に切り替えます。検証ゲートを通したなら、ステップごとに「提案されたが検証に落ちた割合」が手元に残ります。これを誤成功率の代理指標として監視し、一定を超えたらジョブを止める サーキットブレーカーを入れます。
指標 意味 目安のしきい値
検証失敗率 提案のうち事後条件を満たさなかった割合 直近50ステップで 20% 超で警告
再試行後失敗率 差し戻し後も達成できなかった割合 10% 超でジョブ停止
カナリア欠落 アンカーが消えた数 1 以上で当該サイト停止
// src/agent/breaker.ts
export class VerificationBreaker {
private window : boolean [] = [];
constructor ( private size = 50 , private maxFailRate = 0.2 ) {}
record ( verified : boolean ) : void {
this .window. push (verified);
if ( this .window. length > this .size) this .window. shift ();
}
shouldHalt () : boolean {
if ( this .window. length < this .size) return false ;
const fails = this .window. filter (( v ) => ! v). length ;
return fails / this .window. length > this .maxFailRate;
}
}
緑のログを並べることが目的ではありません。間違ったまま走り続けないこと が目的です。検証失敗率が上がったら、それはエージェントが悪いのではなく、たいてい対象の UI が変わったサインなので、止めて観測点を直し、また走らせる——この循環を回せる状態にしておくのが、24時間任せる前提の最低条件だと考えています。
次の一手
もし既存のブラウザエージェントが goalAchieved の自己申告で完了を判定しているなら、まずは一番取り返しのつかない操作 (決済・送信・削除など)に一つだけ、状態で測れる事後検証を足してみてください。スクリーンショットではなく DOM の事実で合否を取る——その一箇所を変えるだけで、朝のログの意味が「たぶん成功」から「検証済みの成功」に変わります。そこから観測点を増やし、カナリアとサーキットブレーカーへ広げていくのが、無理のない順番です。