[Claude Code] 守ってほしい制約はpromptの指示だけでは守られない

背景

GitHub Actions上でClaude Codeを使い、PRの差分を読んで自動でレビューコメントを書くエージェントを運用している。差分を読む範囲、調査に使えるtool呼び出しの上限、出力するJSONの形式など、エージェントに守ってほしい制約はいくつもある。最初はそのすべてをprompt(自然文の指示)で書いていた。「このディレクトリは読むな」「呼び出しは合計何回まで」「出力は必ずこの形式で」というように。

運用を続けるうち、この書き方には共通の弱点があると分かってきた。promptの指示はエージェントへの「お願い」であり、実行のたびに正しく守られる保証ではない。実際に起きた4つの失敗から、制約をどう機械的に強制するかをまとめる。

課題

promptで書いた制約が破られたとき、原因はいずれも同じ形をしていた。「守ってほしいこと」を自然文で説明しただけで、それを検査する仕組みを用意していなかった。エージェントの判断が1回でも外れると、そのまま失敗が本番に出る。

対処: 制約は機械的に強制する

1. 読んでよい範囲をpermissionsで塞ぐ

レビュー対象のPRファイルを、リポジトリ本体とは別に、read-onlyな一時ディレクトリへ展開して渡していた。promptには次のように書いていた。

# prompt(自然文の指示)の例
以下のディレクトリだけを読んでよい:
- /pr-input/  … このPRで変更された内容(read-only)
- /workspace/ … 差分に含まれない、安定したファイルのみ

/pr-input/ に含まれるファイルを /workspace/ 側から読んではならない。

/workspace/は他のブランチをcheckoutした本体であり、/pr-input/にあるべき「変更後のファイル」と同じパスのファイルが/workspace/側にも存在し、そちらからも読めてしまう。「差分に含まれるか」の判定をエージェント自身にさせていたため、この判定を誤ると、変更前(または無関係な)内容を「レビュー対象」として扱ってしまうことがあった。

対処は、/workspace/配下へのReadを権限で拒否することだった。(Read(/path)の単一スラッシュは起動ディレクトリからの相対パスとして解決されるため、起動ディレクトリに関わらず確実に塞ぐには//workspace/**のように先頭を二重スラッシュにする必要がある)

{
  "permissions": {
    "deny": ["Read(//workspace/**)", "Edit(//workspace/**)"]
  }
}

permissionsの層で「そもそも読めない」状態を作れば、パスの分類の正誤をエージェントが判断する必要自体はなくなる。

2. 予算単位のtool呼び出し回数への統一

調査に使えるtool呼び出しの上限を、promptで「ターン(応答)数」として伝えていた。

# prompt(自然文の指示)の例
調査に使えるのは最大14ターン(応答)です。
1回の応答内で複数のtoolを並列に呼んでもよいですが、
残りターン数を自分で数えて、超えないように調整してください。

1回の応答内で複数のtoolを並列に呼び出すと、「応答の回数」と「実際のtool呼び出し回数」は一致しない。1ターンあたり3回のtoolを並列に呼ぶエージェントを想定し、tool呼び出し回数の上限を40回として機械的に数えるとどうなるか確認した。

let callCount = 0;
const BUDGET = 40;

function onPreToolUse() {
  callCount++;
  if (callCount > BUDGET) {
    return { permissionDecision: 'deny', reason: `budget exceeded (${callCount}/${BUDGET})` };
  }
  return null;
}

// 1ターンにtoolを3回並列で呼ぶエージェントを14ターン分シミュレートする
let denied = null;
for (let turn = 0; turn < 14 && !denied; turn++) {
  for (let parallel = 0; parallel < 3; parallel++) {
    const result = onPreToolUse();
    if (result) {
      denied = { turn, callCount };
      break;
    }
  }
}

console.log(denied); // { turn: 13, callCount: 41 }

14ターンの自己申告では「まだ余裕がある」つもりでも、実際のtool呼び出しはその時点で41回に達し、機械的に数えた上限の40回を超えている。エージェントが自分の消費量を見積もる限り、この単位のズレは埋まらない。

対処は、予算の単位を「tool呼び出し1回」に統一し、実行環境(PreToolUse hookなど)が数えるカウンタだけを予算の正とすることだった。並列に呼んでも1回ごとに加算されるため、単位のズレが起きなくなる。

3. 大きな入力の事前分割

差分ファイルが大きいとき、Readツールのoffset/limitで少しずつ読ませていた。

# prompt(自然文の指示)の例
差分ファイルはoffset/limitで少しずつ読んでください。
最初はoffset=1、次のoffsetは直前に表示された最終行番号+1です。

出力が大きすぎて表示が打ち切られると、「続きはoffset=Nから」という注記が付く。この注記をエージェントが見落とすと、次のoffsetを誤って計算し、一部の行が未読のまま先へ進んでしまう。しかもエージェント自身は「最後まで読んだ」つもりでレビュー済みと申告するため、欠落が結果に残らない。

対処は、offsetの計算をエージェントにやらせず、決定論的にサイズの上限を決めたchunkへ事前分割することだった。

function splitIntoChunks(text, maxBytesPerChunk) {
  const chunks = [];
  const lines = text.split('\n');
  let current = '';
  lines.forEach((line, i) => {
    const sep = i < lines.length - 1 ? '\n' : '';
    // 現在のchunkに行を足すと上限を超える場合は、そこで区切る
    if (Buffer.byteLength(current + line, 'utf8') > maxBytesPerChunk && current !== '') {
      chunks.push(current);
      current = '';
    }
    current += line + sep;
  });
  if (current) chunks.push(current);
  return chunks;
}

50行のダミー差分を1chunkあたり300byte前後で分割すると、7個のchunkに割れる。

// 約1.8KBのダミー差分(50行)
const diffSample = Array.from(
  { length: 50 },
  (_, i) => `${i % 3 === 0 ? '+' : i % 3 === 1 ? '-' : ' '} const value${i} = compute(a, b, c);`,
).join('\n');

const chunks = splitIntoChunks(diffSample, 300);
// chunks.length === 7
// chunks.join('') はdiffSampleと完全に一致する(欠落なし)

エージェントには「chunk1〜chunk7をそれぞれ1回ずつReadする」という、計算を伴わない操作だけを残す。複数chunkの並行Readも安全にできる。offsetの計算やページ境界の見落としという失敗の種類そのものを、設計から消している。さらに、実際のRead呼び出し回数がchunk数と一致するかをPostToolUse hookなどで数え、一致しなければ「未読了」として扱うと、Readの回数が足りない形の読み飛ばしも機械的に検知できる。

4. 出力形式のschema検査

出力するJSONの形式を、promptで指定していた。

# prompt(自然文の指示)の例
出力は必ず次のJSON形式にしてください:
{ "id": "...", "verdict": "confirmed" | "rejected", "reason": "..." }

同じpromptで同じエージェント定義を使っても、出力形式はぶれた。idが抜けたり、verdictが自由記述の文章になったり、配列でラップされたりする。手元で同一のエージェント定義を6回実行したところ、6回とも出力形式が異なっていた。

対処は、出力を「守らせる」のではなく、守っているかを機械的に検査することだった。

const REQUIRED_FIELDS = ['id', 'verdict', 'reason'];
const VALID_VERDICTS = ['confirmed', 'rejected'];

function assertShape(output) {
  // 必須フィールドの欠落と、verdictの値域を検査する
  for (const field of REQUIRED_FIELDS) {
    if (!(field in output)) throw new Error(`missing field: ${field}`);
  }
  if (!VALID_VERDICTS.includes(output.verdict)) {
    throw new Error(`invalid verdict: ${output.verdict}`);
  }
}

idを含めなかった出力はmissing field: id、verdictを自由記述で書いた出力はinvalid verdictの例外になることを確認した。検査に落ちた出力は受理せず、再実行かエラーとして扱う。schemaの必須フィールドと、それを検査する決定論的なスクリプトの組が、契約そのものになる。

まとめ

4つの失敗への対処はいずれも同じ形をしている。判定をエージェントに委ねず、権限システム、hookによる呼び出し回数の計測、決定論的な前処理、schemaの検査など、エージェントを経由しない場所で制約を強制する。「守られなければ困る」制約ほど、prompt以外の層に置く価値が大きい。

参考