[Claude Code] AIエージェントが「実行しました」という自己申告を検証する設計

背景

複数のAIエージェントに作業を分担させ、親エージェントがその結果を集約するという構成が一般的になってきた。親エージェントがサブエージェントの作業結果を知る手段は、実質的に2種類しかない。サブエージェントが返す自然言語の報告文と、実行環境が機械的に記録するメタデータ(何回ツールを呼んだか、実行時間はどれくらいか、など)である。

ここには見落としやすい罠がある。報告文を生成しているのはサブエージェント自身、つまり検証対象のモデルそのものである。モデルは「ファイルを読み込んで内容を確認した」という文章を、実際にファイルを読み込んだかどうかに関わらず、同じ流暢さで生成できる。報告文がどれだけ具体的で自信に満ちていても、それ自体は実行の証拠にはならない。

課題

次のようなサブエージェントへの依頼を考える。

config.jsonを読み込み、timeoutとretryの設定値を確認して報告せよ。

実際にファイルを読んだ場合、tool_useブロックを含む応答が返ってくる。

[
  { "type": "text", "text": "config.jsonを確認する。" },
  {
    "type": "tool_use",
    "id": "toolu_01",
    "name": "read_file",
    "input": { "path": "config.json" }
  }
]

一方、ファイルを一切読まずに生成した場合は、次のようにテキストブロックだけの応答になり得る。

[{ "type": "text", "text": "config.jsonを確認した。timeoutは30、retryは3に設定されている。" }]

問題は、この2番目の応答が返ってきたとき、報告文だけを見ても実行の有無を判別できないことである。「確認した」という文言は、実際に確認した場合とまったく同じように書ける。一般的な設定ファイルにありがちな値を書いただけの可能性もあれば、たまたま実際の値と一致しているだけの可能性もある。文面の自然さや具体性は、実行の証拠を何も保証しない。

報告フォーマット自体が自己申告を誘発するケースもある。次のようなレビュー結果のテンプレートをサブエージェントに指定したとする。

## 検証結果

- [ ] 既知の脆弱性データベースと照合済み
- [ ] ハードコードされた認証情報がないことを確認済み
- [ ] 入力値のバリデーションを確認済み

このテンプレートは「照合・確認は完了しているはず」という前提を先に埋め込んでいる。サブエージェントが実際にはデータベースに照合する手段を持たない、あるいは照合を省略した場合でも、チェックボックスを埋めること自体が期待された出力の形になってしまう。フォーマットが結果を先取りしていると、モデルはその形を満たす方向に出力を寄せる。

解決方法

ツール呼び出し件数による判定

Claude APIのメッセージは、テキストとツール呼び出しがそれぞれ独立したcontentブロックとして並ぶ配列であり、ツールを呼び出した応答にはtool_useブロックが1個以上含まれる(Tool use overview)。この構造は、サブエージェントの実行結果を扱うオーケストレーション層であれば、報告文とは別に機械的に取得できる。

これを使って、「検証済み」を示す語を含む報告なのにツール呼び出しが0件、という組み合わせを検出する。

type ContentBlock = { type: 'text'; text: string } | { type: 'tool_use'; id: string; name: string; input: unknown };

type AgentRun = {
  content: ContentBlock[];
  closedBook?: boolean;
};

const VERIFICATION_CLAIM_MARKERS = ['確認した', '検証した', '確認済み', '検証済み', 'verified', 'confirmed'];

// ツール呼び出しブロックの件数を数える
function countToolUses(run: AgentRun): number {
  return run.content.filter((block) => block.type === 'tool_use').length;
}

// テキストブロックだけを連結して報告文を取り出す
function extractReportText(run: AgentRun): string {
  return run.content
    .filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
    .map((block) => block.text)
    .join('\n');
}

// 「検証済み」を主張しているのにツール呼び出しが0件なら未検証とみなす
function isUnverifiedClaim(run: AgentRun): boolean {
  if (run.closedBook) {
    return false;
  }
  const claimsVerification = VERIFICATION_CLAIM_MARKERS.some((marker) => extractReportText(run).includes(marker));
  return claimsVerification && countToolUses(run) === 0;
}

isUnverifiedClaimtrueを返した報告は、内容の是非を判定する前に「未検証」として扱い、実際のファイルやログといった一次資料と突き合わせる。これは、報告文が巧みかどうかとは関係なく機械的に判定できる点が重要である。ただし検出できるのは、VERIFICATION_CLAIM_MARKERSに列挙した特定の自己申告表現を使った場合に限られる。ツール呼び出し0件のまま、これらの語を避けて断定的に値を書けば、この仕組みはすり抜けられる。

根拠つきスキーマによる部分的捏造の検出

ツール呼び出し件数だけを見る方法には限界がある。1回の応答に複数のtool_useブロックが並ぶ並列ツール呼び出しを前提にすると、5個の主張のうち1個しか裏付けとなるツール呼び出しがない、といった部分的な捏造は件数の比較だけでは検出できない。

より厳密にするには、報告のフォーマット自体を変える。主張ごとに根拠を明示する構造にすればよい。ClaudeのStructured Outputsoutput_config.format)を使うと、出力のスキーマでこれを強制できる。

{
  "type": "object",
  "properties": {
    "claims": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "statement": { "type": "string" },
          "evidence_tool_call_id": { "type": "string" }
        },
        "required": ["statement", "evidence_tool_call_id"],
        "additionalProperties": false
      }
    }
  },
  "required": ["claims"],
  "additionalProperties": false
}

このスキーマでは、主張(statement)ごとに、根拠となったツール呼び出しのID(evidence_tool_call_id)を必須にしている。「完了/未完了」を直接書かせるチェックボックスとは違い、「その主張はどのツール呼び出しに由来するか」を書かせる。根拠を示せない主張は、スキーマ違反として機械的に弾かれる。

冒頭の脆弱性チェックリストの例も同様に書き換えられる。「照合した/しなかった」の二値ではなく、各項目の根拠となったツール呼び出しIDを要求すればよい。これなら、照合していないのに済ませてしまう逃げ道がなくなる。

エッジケースへの対応

このチェックには誤検出しうるケースが1つある。サブエージェントへの依頼内容によっては、ツールを一切呼ばずに与えられた情報だけから結論を出すことが正しい振る舞いになる場合がある。たとえば「このプロンプトの文面だけから設計意図を推測せよ」のような、資料がすべて依頼文に埋め込まれた閉じたタスク(クローズドブック)である。

このような依頼を出す側は、ツール呼び出しが0件になることをあらかじめ知っているはずである。そのため、closedBookのようなフラグを依頼側から明示し、チェックの対象から除外する。

// 資料が依頼文にすべて埋め込まれたクローズドブックなタスクの例
const closedBookReasoning: AgentRun = {
  content: [{ type: 'text', text: '与えられた仕様だけから、この設計は要件を満たしていると確認した。' }],
  closedBook: true,
};

逆に、依頼側が明示していないのにツール呼び出しが0件で「確認した」という報告が返ってきた場合は、クローズドブックのつもりだったのか、単に実行を省略したのかが区別できない。区別できないこと自体を「未検証」の合図として扱うのが安全である。

検証

3パターンのAgentRunisUnverifiedClaimの判定を確認する。

const fabricated: AgentRun = {
  content: [{ type: 'text', text: 'config.jsonを確認した。timeoutは30、retryは3であることを検証済み。' }],
};

const legitimate: AgentRun = {
  content: [
    { type: 'text', text: 'config.jsonを確認する。' },
    { type: 'tool_use', id: 'toolu_01', name: 'read_file', input: { path: 'config.json' } },
  ],
};

console.log('fabricated:', isUnverifiedClaim(fabricated));
console.log('legitimate:', isUnverifiedClaim(legitimate));
console.log('closedBookReasoning:', isUnverifiedClaim(closedBookReasoning));

Node.jsの型ストリッピング機能で実行すると、意図した通りの結果になる。型ストリッピングはNode.js 22.6で実験的機能として追加され、22.18/23.6以降は既定で有効なため、フラグなしでnode ファイル.tsのように実行できる。

fabricated: true
legitimate: false
closedBookReasoning: false

「検証済み」を含みながらツール呼び出しが0件の応答だけがtrueになり、実際にツールを呼んだ応答とクローズドブックとして明示された応答はfalseのままである。

まとめ

自然言語の報告文は、それを生成したモデル自身の実行状況を保証しない。報告文がどれだけ具体的で自信に満ちていても、検証の材料としては扱えない。

検証は、モデルが文章として語れない場所に置く必要がある。ツール呼び出しの回数のような実行環境側のメタデータとの突き合わせは最も手軽な第一歩である。根拠を主張ごとに要求する出力スキーマへの置き換えは、根拠の不在を機械的に防ぐ。ただしevidence_tool_call_idは、それが実在のツール呼び出しを指しているかどうかしか検証しない。そのツール呼び出しの実際の結果とstatementの内容の対応関係については、オーケストレーション層による別途の確認が必要である。逆に、「検証済み」を前提としたチェックボックス形式の出力テンプレートは、モデルに埋めることを促してしまうため避けたほうがよい。

参考