ストアの価格改定の手順をツールとして書き出していた夜、apply_price_change という名前を打ち終えたところで手が止まりました。入力スキーマまで書けていて、あとは tools 配列に足すだけの状態でした。足してしまえば、その日から Claude は必要だと判断した瞬間にそれを呼びます。
私は個人開発でいくつかのアプリを配信していて、価格や配信まわりの設定はストアの管理画面から自分の手で触ってきました。手順そのものは短くて、書き出せば十数行です——短いからこそツールにしたくなります。手が止まったのは難しさではなく、取り消しにかかる手間と、呼び出しの手軽さが釣り合っていない、という感触のほうでした。
9月2日に Anthropic が商取引エージェントのブループリントを公開しました。小売・旅行・通信・チケッティング向けの参照実装で、買い手に向き合うショッピングエージェントと、店舗運営者に向き合うマーチャントエージェントの2系統があります。読んでいていちばん目を引いたのは機能の一覧ではなく、ショッピングエージェントが「組み上げたカートをチェックアウトへ引き渡す」ところで止まっていることでした。決済の確定は、エージェントの持ち物として数えられていないのです。
承認を足す前に、渡さない操作を決める
取り消せない操作を扱う方法として、まず思いつくのは承認ゲートだと思います。ツールが呼ばれたら実行を保留し、人が見てから通す仕組みです。私も使っていますし、要る場面は確かにあります。
ただ、ゲートは「呼ばれたあとに止める」仕掛けです。止める側が動いていることが前提になります。通知が届かなかったとき、保留の期限が切れたとき、ゲートの分岐に書き漏らしがあったとき、既定の振る舞いがどちらに倒れるかで結末が変わります。倒れた先が実行だった場合、事故はゲートを入れる前と同じ形で起きます。
一方で、ツール一覧に名前がなければ、モデルはその操作を呼びようがありません。止める仕掛けの健全性に依存しない、という一点だけで、ずいぶん眠りやすくなりました。
取り消しに人の判断が要る操作は、ツール一覧に名前を置きません。 私はこの線引きを、承認ゲートより先に置くようにしています。ゲートは、線の内側に残った操作を細かく制御するための道具として使えばよいのだと考えています。
ゲートそのものの作り方——中断と再開、非同期の通知、期限切れの扱い——はエージェントに「承認ゲート」を差し込むに詳しく書いています。この記事で扱うのは、その手前の棚卸しのほうです。
助言・下ごしらえ・確定の三段に分ける
最初のうち、私は二段で分けていました。読み取りと書き込みの二つです。結果は芳しくありませんでした。書き込みという一つの箱に、下書きの保存と、本番への反映が同居してしまうからです。同じ「書き込み」でも、前者は消せて、後者は消せません。
いまは三段にしています。
| 段 | エージェントの持ち分 | 戻り値の形 | 例 |
|---|---|---|---|
| 助言 | 調べる・比べる・薦める | 候補の一覧、比較結果 | カタログ検索、在庫の確認、値付けの提案 |
| 下ごしらえ | 組み立てる・下書きを作る | 識別子と状態を持つ下書き | カートの組み立て、返信文の草案、価格変更の下書き |
| 確定 | 渡しません | — | 決済、送信、公開、削除、返金 |
ブループリントの二つのエージェントを、この三段に当てはめてみると分かりやすくなります。ショッピングエージェントは検索と比較で助言し、カートを組み上げて下ごしらえまで進み、チェックアウトへ引き渡します。マーチャントエージェントは売上の状況に答え、在庫の異常を知らせ、値付けや販促を提案し、施策の草案を書きます。どちらも三段目に踏み込みません。
境目の判断で迷ったときは、「これを取り消すのに、私以外の誰かの手を借りる必要があるか」を自分に聞くようにしています。返金の申請、送ってしまったメールの撤回、公開した価格の巻き戻し——このどれもが、私の手だけでは閉じません。閉じないものが三段目です。
なお、可逆かどうかは操作の名前ではなく置かれた場所で変わります。同じ削除でも、戻せる場所と戻せない場所があります。この測り方は同じ rm -rf が、10箇所では戻せて5箇所では戻せませんでしたにまとめました。
ツール定義を書き換える — 確定を戻り値の下書きに変える
棚卸しの結果は、ツール定義の書き換えとして表れます。手が止まったあの定義は、こう書きかけていたものでした。
# Before: 呼ばれた瞬間に、外の世界が変わります
tools = [
{
"name": "apply_price_change",
"description": "指定した SKU のストア販売価格を新しい価格に変更します。",
"input_schema": {
"type": "object",
"properties": {
"sku": {"type": "string"},
"price_jpy": {"type": "integer"},
},
"required": ["sku", "price_jpy"],
},
}
]書き換えたあとがこちらです。名前と説明文と、そして戻り値の性格が変わっています。
# After: 呼ばれても、変わるのは確認待ちの一覧だけです
tools = [
{
"name": "draft_price_change",
"description": (
"価格変更の下書きを作り、確認待ちの一覧に積みます。"
"ストアの価格は変更しません。反映は人が別の手順で行います。"
),
"input_schema": {
"type": "object",
"properties": {
"sku": {"type": "string"},
"price_jpy": {"type": "integer"},
"reason": {"type": "string", "description": "この値付けにした理由"},
},
"required": ["sku", "price_jpy", "reason"],
},
}
]対応するハンドラは、外の世界に触れずに下書きを返します。
import uuid
PENDING = [] # 実運用では DB やキューに置きます
def draft_price_change(sku: str, price_jpy: int, reason: str) -> dict:
current = load_current_price(sku) # 読み取りだけを行います
draft = {
"draft_id": str(uuid.uuid4()),
"status": "awaiting_review",
"sku": sku,
"from_jpy": current,
"to_jpy": price_jpy,
"reason": reason,
}
PENDING.append(draft)
return draftreason を必須にしたのは、あとから自分が読むためでした。確認待ちの一覧を朝に開いたとき、金額の差分だけが並んでいても判断できません。なぜその値付けなのかが一行あるだけで、通すか戻すかの決め方がずいぶん楽になりました。
戻り値に draft_id と status しか含まれていない、というのがこの書き換えの要点です。モデルから見れば、下書きを作ったという事実だけが返ってきます。ストアの価格は、私が別の画面で確認して反映するまで、以前のままです。
確定が紛れていないかを、ツール名ではなく戻り値で確かめる
ツールが増えてくると、三段のどこにいるのか分からない定義が混ざってきます。目視でも見つかりますが、機械にも一度見てもらうことにしました。名前と説明文から、取り消しづらい動詞を拾うだけの短いスクリプトです。
# tool_commit_lint.py — ツール一覧に「確定」が紛れていないかを調べます
import json
import re
import sys
COMMIT_VERBS = ["place", "submit", "charge", "capture", "refund",
"cancel", "publish", "send", "delete", "deploy", "transfer", "apply"]
DRAFT_MARKERS = ["draft", "proposal", "preview", "plan", "quote", "candidate", "下書き", "確認待ち"]
def lint(tools):
findings = []
for t in tools:
text = f"{t.get('name','')} {t.get('description','')}".lower()
hit = [v for v in COMMIT_VERBS if re.search(rf"\b{v}\w*", text)]
drafty = any(m in text for m in DRAFT_MARKERS)
if hit and not drafty:
findings.append((t.get("name", ""), hit))
return findings
if __name__ == "__main__":
tools = json.load(open(sys.argv[1], encoding="utf-8"))
found = lint(tools)
for name, verbs in found:
print(f"COMMIT? {name} <- {', '.join(verbs)}")
print(f"{len(found)} / {len(tools)} tools need a second look")
sys.exit(1 if found else 0)手元の6件で走らせたときの出力がこちらです。
COMMIT? place_order <- place, charge
COMMIT? apply_price_change <- apply
COMMIT? send_review_reply <- send
3 / 6 tools need a second look
カートを組み立てる build_cart は説明文に preview があるので素通りし、下書きを作る draft_price_change も通りました。一方で send_review_reply は引っかかります。レビューへの返信は、送ってしまえば取り消せません。拾ってほしい種類の指摘でした。
ただ、この程度の見立てで安心するのは早いのです。動詞を選ぶ語感は人によって違いますし、update_status のような穏やかな名前の裏で決済が確定していても、名前を見るかぎり何も起きません。スクリプトは網ではなく、目印を置くための道具として使っています。
最後は戻り値で確かめます。返ってくるのが下書きの識別子と状態だけなら二段目、もう起きてしまったことの報告——注文番号、送信の完了、公開された URL——なら三段目です。名前は書き換えられますが、戻り値は嘘をつきません。
明日の朝にできること
まずは tools 配列を一つだけ開いて、その中の定義を上から順に読みながら、戻り値が「これから起きること」か「もう起きたこと」かを声に出して読み分けてみていただければと思います。私は付箋に三段を書いて画面の端に貼っており、いまも新しいツールを足すたびに、そこへ指を当てて確かめています。
不可逆な操作を渡さないという線引きは、慎重さのためというより、あとで自分が説明できるようにするためのものだと感じています。エージェントに任せる範囲は、これからも広がっていくのだと思います。広げる速さより、広げた先で何が戻せなくなるかを先に数えておくことのほうを、私は大事にしていきたいのです。
お読みくださりありがとうございました。手元の一覧に一件でも見直す価値のあるものが見つかれば、書いた甲斐があります。