はじめに
CLIエージェントからブラウザ内蔵AIエージェントへタスクを投げると、コストは下がる。だが素朴に実装すると、誤送信・重複送信・セッション混線という3つの事故に高確率で遭遇する。この記事は、その3つを防ぐための設計を、実際に動くコードと図付きでまとめたものだ。
Claude Code や Codex のようなCLI型のAIエージェントを使っていると、こういう場面に必ずぶつかる。
「この調査、公開情報の比較だけなんだから、CLIエージェントの高いトークン単価を使うのはもったいない」
一方でブラウザ上のAIエージェント(各種チャットサービスの内蔵AI機能)には、無料〜低コストで使えるが有料上位プランほどの持続力・並列性はない、という枠がよくある。ここに「安いブラウザ内蔵エージェントに一部の仕事を投げる」という発想が生まれる。
素朴にやると壊れる3つのポイント
最初に思いつく実装はだいたいこうだ。
1. CLIエージェントが依頼文を組み立てる
2. ブラウザを開いてチャットUIに貼り付ける
3. 送信ボタンを押す
4. 出てきた返答を拾う
これを繰り返し使うと、次の3つの事故に遭遇する。
① 誤送信: 依頼文に社内限定URLや個人情報が混ざったまま送ってしまう。CLIエージェント側は「このタスクは公開情報の調査」のつもりでも、依頼文を組み立てる過程で機密情報が紛れ込むケースは普通にある。
② 重複送信: ネットワーク遅延やUIのもたつきで「送信できたか分からない」状態になり、もう一度送ってしまう。相手がAIチャットである以上、同じ依頼を2回送ると2回とも律儀に処理されるだけで、エラーにはならない。気づかずに同じ調査を3回課金することもある。
③ セッション混線: 同じチャットを使い回すと、前のタスクの文脈が今回の依頼に影響する。「さっきの話の続きだと思ってこう答えた」という形で、意図しない回答が返ってくる。
これらは全部「送信して受け取るだけ」という設計の単純さが原因になっている。対策は、送信の前後に処理を挟むことだ。
設計1: 送信前に依頼を3分類する
ブラウザ内蔵エージェントに渡していい情報は、事前に分類しておく。
分類A: 公開情報の調査・比較(そのまま送ってよい)
分類B: 無害化済みの情報(固有名詞や識別子を除去した要約に置き換えてから送る)
分類C: 機密情報(送らない。CLIエージェント側で完結させる)
分類Cに入れるべきものは具体的に列挙しておくと事故が減る。
- パスワード・APIキー・認証トークン・Cookie
- 個人情報(顧客・社員・応募者など)
- 非公開コード・差分・ログ・スクリーンショット
- 未公開の計画・脆弱性情報・社内限定URL
ポイントは「迷ったら送らない」をデフォルトにすることだ。分類を機械的に判定しきれない依頼は、無理にAで処理しようとせず、Cとして扱う。この「判断できないものは危険側に倒す」姿勢が、設計3の失敗時対応とも一貫している。
設計2: 1件・1セッション・1検証のサイクル
委譲の単位を「1回の依頼につき、新しいチャット1つ」に固定する。全体の流れを図にするとこうなる。
手順として書くとこうなる。
1. 新規チャットを開く(過去の文脈を持ち込まない)
2. 依頼本文を確定し、実行IDを発行する
3. 送信前チェック(分類・機密情報の混入確認)を通す
4. 依頼を1回だけ送信する
5. 送信後の画面状態を複数回観測し、受付を確認する
6. 返ってきた回答をCLIエージェント側が検証する
(根拠は示されているか、欠落や矛盾はないか)
7. そのタスクで開いたタブだけを閉じる
このサイクルの肝は5番目の「複数回観測」だ。ブラウザの状態を1回見ただけでは、送信が本当に成功したのか、画面がまだ処理中なのかを区別できない。時間を置いて複数回チェックすることで、「送信できたことにして先に進む」という誤判定を防ぐ。
6番目の検証も省略しない。ブラウザ内蔵エージェント側の回答をそのまま鵜呑みにせず、CLIエージェント側で「この回答は依頼の根拠になっているか」「欠けている情報はないか」を必ず一段挟む。委譲先のAIが間違えることは普通にあるので、検証責任は委譲した側に残す。
設計3: 失敗時は「隔離して止まる」
タイムアウト、画面のリロード、クラッシュなどで送信結果が分からなくなることは必ず起きる。ここで一番やってはいけないのが「分からないから、念のためもう一度送る」だ。それは前述の②重複送信そのものになる。
対策はシンプルで、同じ実行IDでの再送を禁止する。最小構成のガードは、こんな形で書ける。
class DedupGuard:
"""実行IDごとの送信状態を管理し、結果不明な実行の再送を防ぐ"""
def __init__(self):
self._state = {} # execution_id -> "sent" | "confirmed" | "unknown"
def can_send(self, execution_id: str) -> bool:
return self._state.get(execution_id) is None
def mark_sent(self, execution_id: str):
self._state[execution_id] = "sent"
def confirm(self, execution_id: str, observed_hash: str, expected_hash: str):
if observed_hash == expected_hash:
self._state[execution_id] = "confirmed"
else:
# ハッシュが一致しない=結果が不明。ここでは絶対に自動再送しない
self._state[execution_id] = "unknown"
def is_blocked(self, execution_id: str) -> bool:
return self._state.get(execution_id) == "unknown"
confirm() の中で unknown になった実行IDは can_send() が False を返すようにしておけば、CLIエージェントがどれだけ「もう一度送ろう」としても物理的に止められる。この「自動では突破できない壁」を1つ噛ませておくことが、事故を防ぐ一番シンプルな方法だと思う。
「分からない時は自動で頑張ろうとせず、止まって人に聞く」という設計は地味だが、AIエージェントを組み合わせる自動化では一番効いてくる部分だと思う。自動化のゴールは「絶対に人の手を止めない」ことではなく、「分からない状態のまま突き進んで被害を広げない」ことだ。
採用しなかった構成
設計を固める過程で、良さそうに見えて見送った構成が3つある。理由込みで書いておく。
上位プラン(追加コストがかかるエージェント枠)
単純にコスト構成上、通常プランのエージェントUIで要件を満たせるなら追加コストを払う必要がない。ここは「機能で選ぶ」より「今回のタスクの要求水準で選ぶ」を優先した。
完全無人でのブラウザ操作
画面のレイアウト変更、認証切れ、利用制限など、ブラウザ側の状態は自分でコントロールできない要素が多すぎる。「1件ずつ、送信前チェックと事後検証を挟む」半自動の構成の方が、結果的に事故の少ない運用になった。
同じチャットの使い回し
効率だけ見れば魅力的だが、前述のセッション混線のリスクとチャット自体の肥大化を考えると割に合わない。1タスク1チャットの原則を崩さない方が長期的に安定する。
費用対効果は測ってから言う
この手の仕組みを作ると「これでコストが節約できる」と言いたくなるが、それは実測してから言うべきだと思っている。
ブラウザ操作を挟むこと自体のオーバーヘッド(待機時間、複数回の状態観測、検証ステップ)がある以上、単純なトークン単価の差だけで「絶対に得」とは言い切れない。タスクの性質によっては、素直にCLIエージェント側で完結させた方が速くて安いこともある。
「委譲すれば必ず得」ではなく、「この種のタスクなら委譲する価値がある」という条件を、自分の実際の使用パターンで測って判断するのがいいと思う。
まとめ
CLIエージェントからブラウザ内蔵AIエージェントへタスクを委譲する構成は、素朴に実装すると誤送信・重複送信・セッション混線という3つの事故に遭遇する。対策は次の3つに集約できる。
- 依頼を送信前に「公開・無害化済み・機密」で分類し、機密は絶対に委譲しない
- 委譲は「1件・1セッション・1検証」を単位にし、送信後の状態を複数回確認してから次に進む
- 結果が不明な時は自動で再送せず、
DedupGuardのような仕組みで物理的にブロックし、人が確認するまで止める
この設計は、Notion・Gemini・GPTなど、どのブラウザ内蔵AIエージェントに委譲する場合でも同じ形で当てはまる。エージェント同士を組み合わせる時に最初に壊れるのは「機能」ではなく「送信と検証の境界」だ、というのがこの仕組みを作ってみての実感だ。