自律エージェントを本番で走らせていて、いちばん肝を冷やすのは、エージェントが「もっともらしく」失敗するときです。クラッシュしてくれたほうがまだ救いがあります。ところが Claude のような言語モデルは、間違った内容を、自信たっぷりに、整った形式で出力します。スキーマは通る。型は合う。ログを見るかぎり成功している。それなのに、書き込まれた中身が壊れている。これがいちばん厄介な失敗の形です。
私は2014年から複数のアプリとサイトを個人で運用していて、ここ数年は記事の更新やデプロイの一部を自律エージェントに任せています。最初に組んだ素朴な仕組みでは、エージェントに git へ push させると、週に1〜2回は壊れた状態が本番に出ていました。リンク先が存在しない、日英の記事数が食い違う、設定ファイルの整合性が崩れている。どれも「プロンプトをもっと丁寧に書けば防げる」と思いがちな失敗です。でも、いくらプロンプトを磨いても、すり抜けはゼロになりませんでした。
結論から言うと、効いたのはプロンプトの改善ではなく、エージェントの書き込み操作を決定論的なゲートで物理的に挟むこと でした。この設計を「契約ゲート(Contract Gate)」として一般化し、Claude Agent SDK のツール呼び出しに後付けできる形で、実装まで順に見ていきます。
なぜ「AIに気をつけてもらう」設計は破綻するのか
多くのエージェント実装は、安全性をプロンプトに委ねています。「壊れたデータを書かないでください」「push する前に整合性を確認してください」といった指示です。これは一見うまくいくのですが、本質的に確率的な防御です。モデルは毎回少しずつ違う判断をします。99%守れても、1日に何十回も走らせれば、残りの1%は確実に表に出てきます。
ここで思い出してほしいのが、ソフトウェア工学で古くから知られている「契約による設計(Design by Contract)」です。関数に事前条件(呼び出してよい状態)と事後条件(呼び出し後に保証される状態)を定め、満たされなければ実行そのものを止める考え方です。自律エージェントのツール呼び出しは、まさにこの契約が必要な場所です。エージェントが何を考えていようと、書き込みの前後で機械が不変条件を検査し、破れていたら適用させない 。判断の主体をモデルから決定論的なコードに移すわけです。
公式ドキュメントの多くは canUseTool のような許可フックを「人間の承認を挟む」用途で説明します。けれど実運用でいちばん効くのは、人間を挟むことではなく、人間もモデルも介在しない決定論的な検査 を挟むことでした。人間の承認は夜間バッチでは機能しませんし、モデルの自己検査は前述のとおり確率的です。
契約ゲートの構造:tryGuard・適用・commitGuard・rollback
契約ゲートは、ひとつの破壊的操作を次の4段で包みます。
tryGuard(事前条件) — 操作してよい状態かを検査する。破れていたら操作を実行せず拒否する。
snapshot(巻き戻し点) — 操作前の状態を記録する。
apply(適用) — 実際の書き込みを行う。
commitGuard(事後条件) — 書き込み後の状態が不変条件を満たすか検査する。破れていたら snapshot まで rollback する。
ポイントは、事後条件で「適用してみてから検査し、ダメなら巻き戻す」という流れを持つことです。事前条件だけでは「適用した結果どうなるか」までは検査できません。たとえば「日本語記事と英語記事の本数が一致していること」という不変条件は、ファイルを書いた後でなければ確かめられません。だからこそ、適用と検査と巻き戻しを一体で扱う必要があります。
TypeScript で契約ゲートを実装する
下のコードは、任意の副作用関数を契約ゲートで包む最小実装です。Claude Agent SDK のツール定義に被せて使えます。依存はなく、そのまま動きます。
// contract-gate.ts
export type GuardResult = { ok : true } | { ok : false ; reason : string };
export interface Contract < Ctx > {
name : string ;
// 適用前に検査する不変条件。複数返してよい。
pre : ( ctx : Ctx ) => Promise < GuardResult []> | GuardResult [];
// 巻き戻しに必要な状態を捕捉する。
snapshot : ( ctx : Ctx ) => Promise < unknown > | unknown ;
// 実際の破壊的操作。
apply : ( ctx : Ctx ) => Promise < void > | void ;
// 適用後に検査する不変条件。
post : ( ctx : Ctx ) => Promise < GuardResult []> | GuardResult [];
// 事後条件が破れたときに snapshot まで戻す。冪等であること。
rollback : ( ctx : Ctx , snapshot : unknown ) => Promise < void > | void ;
}
export class ContractViolation extends Error {
constructor (
public phase : "pre" | "post" ,
public contract : string ,
public reasons : string [],
) {
super ( `[${ contract }] ${ phase } 条件違反: ${ reasons . join ( "; " ) }` );
this .name = "ContractViolation" ;
}
}
export async function runWithContract < Ctx >(
contract : Contract < Ctx >,
ctx : Ctx ,
) : Promise < void > {
// 1. 事前条件
const preResults = await contract. pre (ctx);
const preFailed = preResults. filter (( r ) => ! r.ok) as { reason : string }[];
if (preFailed. length > 0 ) {
throw new ContractViolation ( "pre" , contract.name, preFailed. map (( r ) => r.reason));
}
// 2. 巻き戻し点
const snap = await contract. snapshot (ctx);
// 3. 適用
await contract. apply (ctx);
// 4. 事後条件 → 破れていたら巻き戻す
const postResults = await contract. post (ctx);
const postFailed = postResults. filter (( r ) => ! r.ok) as { reason : string }[];
if (postFailed. length > 0 ) {
await contract. rollback (ctx, snap);
throw new ContractViolation ( "post" , contract.name, postFailed. map (( r ) => r.reason));
}
}
このゲートを Claude Agent SDK のツールに被せると、エージェントが「push する」と判断しても、契約が破れていれば ContractViolation が投げられ、ツールはエラーを返します。エージェントから見ると、ツールが「その操作はできません、理由はこれです」と答えてくる形になります。モデルはその理由を読んで別の手を考えますが、たとえモデルが暴走しても、不変条件が破れた状態は決して本番に残りません 。
// 記事を公開するツールに契約ゲートを被せる例
import { runWithContract, type Contract } from "./contract-gate" ;
import { execSync } from "node:child_process" ;
import { readdirSync } from "node:fs" ;
interface PublishCtx {
repoPath : string ;
jaFile : string ;
enFile : string ;
}
const publishContract : Contract < PublishCtx > = {
name: "publish-article" ,
pre : ( ctx ) => {
const results = [];
// 事前条件1: 日英ファイルが両方そろっている
results. push (
ctx.jaFile && ctx.enFile
? { ok: true as const }
: { ok: false as const , reason: "日本語版または英語版が欠落しています" },
);
// 事前条件2: 作業ツリーがクリーンで、想定外の変更がない
const dirty = execSync ( `git -C ${ ctx . repoPath } status --porcelain` ). toString (). trim ();
const unexpected = dirty. split ( " \n " ). filter (( l ) => l && ! l. includes ( "content/articles/" ));
results. push (
unexpected. length === 0
? { ok: true as const }
: { ok: false as const , reason: `想定外の変更: ${ unexpected . join ( ", " ) }` },
);
return results;
},
snapshot : ( ctx ) =>
execSync ( `git -C ${ ctx . repoPath } rev-parse HEAD` ). toString (). trim (),
apply : ( ctx ) => {
execSync ( `git -C ${ ctx . repoPath } add content/` );
execSync ( `git -C ${ ctx . repoPath } commit -m "Add: article (JA+EN)"` );
},
post : ( ctx ) => {
const ja = readdirSync ( `${ ctx . repoPath }/content/articles/ja` , { recursive: true })
. filter (( f ) => String (f). endsWith ( ".mdx" )). length ;
const en = readdirSync ( `${ ctx . repoPath }/content/articles/en` , { recursive: true })
. filter (( f ) => String (f). endsWith ( ".mdx" )). length ;
// 事後条件: コミット後、日英の本数が一致している
return [
ja === en
? { ok: true as const }
: { ok: false as const , reason: `日英本数不一致 ja=${ ja } en=${ en }` },
];
},
rollback : ( ctx , snap ) => {
// コミットを取り消して元の HEAD に戻す(冪等)
execSync ( `git -C ${ ctx . repoPath } reset --hard ${ snap }` );
},
};
// ツール実行時に呼ぶ
await runWithContract (publishContract, { repoPath, jaFile, enFile });
事前条件・事後条件に何を書くか
ここがいちばん設計判断を要する部分です。契約に書いてよいのは、決定論的に、外部状態への問い合わせだけで真偽が確定する不変条件 だけです。「この記事は読者にとって価値があるか」のような主観は契約に書けません。それはモデルの仕事であって、ゲートの仕事ではありません。
私が実際に置いている不変条件は、たとえば次のようなものです。日英の記事本数が一致している。内部リンクの飛び先が実在する。設定ファイルにリダイレクトと実体の矛盾がない。これらはすべて、ファイルシステムや git に問い合わせれば一意に真偽が決まります。一方で、文章の自然さや独自性の有無は、別レイヤー(モデル自身や、別の判定モデル)に任せます。
判断の指針はシンプルです。「人間のレビュアーが目視で5秒以内に、議論の余地なく合否を言えるもの」だけを契約に入れる こと。少しでも解釈が割れるものを契約に入れると、誤検知でエージェントが止まり続け、結局ゲートを外したくなります。ゲートは、外したくならない厳密さで設計してはじめて生き続けます。
もうひとつ、公式の解説ではあまり強調されない点を挙げます。事後条件は「自分が今書いた範囲」だけでなく「リポジトリ全体の整合性」を検査するべき です。エージェントの書き込みは、しばしば直接触っていない場所の前提を壊します。たとえば記事を1本足しただけのつもりが、サイトマップの整合性を崩していることがあります。事後条件をリポジトリ全体に広げると、この種の「波及した破壊」を捕まえられます。検査コストは上がりますが、本番が壊れるコストに比べれば無視できます。
ロールバックを安全にする:スナップショットと冪等な巻き戻し
契約ゲートの弱点は rollback です。ここがバグっていると、事後条件で異常を検知したのに巻き戻しに失敗し、かえって中途半端な状態を残します。これは「安全装置が事故を起こす」最悪のパターンです。
実装で守るべき原則は2つあります。第一に、rollback は冪等であること 。同じ snapshot で何度呼んでも同じ最終状態になるように書きます。上の例では git reset --hard <sha> を使っていますが、これは何度実行しても結果が同じです。第二に、snapshot は apply の前に必ず捕捉すること 。当たり前に思えますが、apply の途中で例外が出てから snapshot を取ろうとする実装を実際に見たことがあります。それでは手遅れです。
ファイルシステムを直接いじる操作では、git のような巻き戻し機構がない場合があります。その場合は、apply を一時ディレクトリで行い、事後条件を通過してから本番ディレクトリへアトミックに rename で差し替える「ステージング方式」が有効です。rename は多くのファイルシステムでアトミックなので、事後条件が破れたら一時ディレクトリを捨てるだけで、本番は一切汚れません。
実運用で見えた数値と落とし穴
素朴な実装では、前述のとおり週に1〜2回、壊れた状態が本番に出ていました。契約ゲートを導入してからの約3ヶ月で、ゲートが事前条件・事後条件で弾いた回数は合計で47回、そのうちすり抜けてしまった破壊はゼロです。内訳は事前条件での拒否が30回、事後条件での巻き戻しが17回でした。
興味深いのは、弾かれた47回のうち、モデルが明らかに「間違えた」ものは半分以下 だったことです。残りは、外部要因(前回のジョブが中途半端に終わっていた、リモートが先に更新されていた、ファイルシステムが一時的に不整合だった)でした。つまり契約ゲートは、モデルの誤りだけでなく、分散システムにつきものの競合状態も同じ網で捕まえてくれていました。これは設計時には期待していなかった副次効果です。
落とし穴も書いておきます。最大の罠は、事後条件が破れたときにエラーをログに出すだけで、エージェントのループにフィードバックしない 実装です。これだとエージェントは何が起きたか分からず、同じ操作を延々とリトライします。ContractViolation の reason を必ずツールの返り値としてモデルに返し、モデルが原因を踏まえて修正できるようにしてください。ゲートは壁ではなく、対話の相手として設計するのが正解です。
既存エージェントへの後付け手順
すでに動いているエージェントに契約ゲートを入れるなら、いきなり全ツールを包む必要はありません。次の順で段階的に入れるのが安全です。
まず、最も破壊的な1ツール (多くの場合 push やデプロイ、データベース書き込み)だけを選び、そこに事後条件だけを付けます。事前条件より事後条件のほうが、既存の挙動を壊さずに「気づき」を得られます。次に、事後条件のログを1〜2週間眺めて、どんな破れ方が実際に起きるかを観察します。そこで見えたパターンを事前条件に昇格させると、巻き戻しの回数自体を減らせます。最後に、ステージング方式やリポジトリ全体検査といった重い仕組みを、必要な箇所にだけ追加します。
この順番が大事なのは、契約は机上で完璧に書けないから です。実際に走らせて、エージェントがどう間違えるかを観察してはじめて、置くべき不変条件が分かります。
私の祖父はふたりとも宮大工でした。木組みは、釘で無理に留めるのではなく、力がかかったときに自然と締まる形にあらかじめ刻んでおくものだと聞きました。契約ゲートも同じだと感じています。エージェントを叱って正そうとするのではなく、間違えても壊れない形に、あらかじめ構造を刻んでおく。自律エージェントを安心して夜間に走らせられるかどうかは、この「あらかじめ刻んでおく」設計に懸かっているのだと思います。
次に試すなら、あなたのエージェントで最も怖い1ツールを選び、まず事後条件をひとつだけ書いてみてください。「この操作のあと、何が真であってほしいか」を1行で言えたなら、契約ゲートはもう半分できています。