Claude API AIエージェント Claude API PHP ツール使用

Claude Codeが賢く見える理由の半分は、モデルではなく構造にあります。「AIが次に見たいものをツール呼び出しで要求し、こちらが実行して結果を返し、AIが続きを判断する」——このループ自体はAPIの標準機能だけで作れて、PHPからでも動きます。本記事は、WordPressの自動処理に組み込んだツール使用エージェントループの実装を、安全設計込みで解説します。

何に使うのか — 「どのファイルが要るか事前に分からない」タスク

きっかけは、文章生成の参照資料として「トピックに関係するソースコード」を集める処理でした。最初は決め打ちの追跡(テンプレート→ルート→コントローラ)で書いていましたが、ハンドラが呼ぶ先のサービス層・フォーム型・設定ファイルまでは辿れません。かといって辿り方のパターンを全部コードに書くのは無理があります。

そこで発想を変えて、AIにgrep・ファイル読み・一覧の3ツールを渡し、「必要なファイルを自分で探させる」ことにしました。まさにClaude Codeがやっていることの縮小版です。

ループの構造 — stop_reasonがtool_useの間だけ回す

Messages APIにツール定義を渡すと、モデルは「このツールをこの引数で実行したい」という応答(stop_reason: "tool_use")を返してきます。こちらはツールを実行して結果を会話履歴に足し、また呼ぶ。モデルが最終回答を返したら終了です。

$messages = array( array( 'role' => 'user', 'content' => $prompt ) );

for ( $turn = 0; $turn < $max_turns; $turn++ ) { $body = api_call( array( 'model' => $model,
        'system'   => $system,
        'tools'    => $tools,     // ツール定義(JSON Schema)
        'messages' => $messages,
    ) );

    if ( 'tool_use' !== $body['stop_reason'] ) {
        return extract_text( $body['content'] ); // 完了
    }

    // モデルの応答(thinking含む)を無改変で履歴に戻すのが重要
    $messages[] = array( 'role' => 'assistant', 'content' => $body['content'] );

    $results = array();
    foreach ( $body['content'] as $block ) {
        if ( 'tool_use' === $block['type'] ) {
            $results[] = array(
                'type'        => 'tool_result',
                'tool_use_id' => $block['id'],
                'content'     => run_tool( $block['name'], $block['input'] ),
            );
        }
    }
    $messages[] = array( 'role' => 'user', 'content' => $results );
}

実装上の注意を3つ。①応答ブロックは無改変で返す。拡張思考を有効にしていると応答にthinkingブロックが含まれますが、これを削って履歴に戻すとエラーになります。もらったcontentをそのまま入れるのが正解です。②ターン上限を必ず切る。探索が収束しない暴走への保険です(うちは10ターン)。③空引数のツールに注意。PHPで応答をjson_decode($body, true)すると、引数なしのinput: {}が空配列[]になり、履歴として送り返すときにJSON配列へ化けて「Input should be an object」で弾かれます。ツールに必須引数を1つ持たせるのが簡単な回避策です。

ツール側の安全設計 — AIに渡すのは「読み取り専用の檻の中」だけ

ループ自体より大事なのがここです。AIが要求してくるパスや検索パターンは信用できない入力として扱います。

  • 全ツールを読み取り専用にする — 書き込み系のツールはそもそも渡さない
  • パスはrealpathで正規化して檻に閉じ込める../ やシンボリックリンク経由で対象リポジトリの外に出るパスは拒否する
  • 出力に上限を切る — grepは60行まで、ファイル読みは2万字まで、一覧は200件まで。上限が無いと巨大ファイル1つで入力トークンが爆発する
  • vendor / node_modules / .git を走査対象から外す — ノイズ削減とコスト削減の両方に効く
function jail( $rel, $repo_root ) {
    $real = realpath( $repo_root . '/' . ltrim( $rel, '/' ) );
    if ( false === $real || 0 !== strpos( $real, $repo_root . '/' ) ) {
        return false; // リポジトリ外へのアクセスは拒否
    }
    return $real;
}

役割は限定する — 「探させる」が正解で「要約させる」は失敗だった

このエージェントの最終出力は「関係するファイルの相対パスと理由の一覧(JSON)」だけにしています。見つけたファイルの内容を要約させることはしません。選ばれたファイルは呼び出し側が全文を後工程に渡します。

最初は要約もさせていたのですが、要約は必ず情報を落とします。後工程が必要とする細部(引数の既定値、条件分岐の順序)が要約で消え、成果物の品質が下がる事故を繰り返しました。エージェントに賢い仕事を任せるほど気持ちいいのですが、「判断はAI、運搬はプログラム」に分けたほうが結果は安定します。

コストの注意 — このループは履歴を毎ターン再送する

最後に運用面の注意をひとつ。この構造はターンごとに「それまでの履歴全体」を再送するため、ツール結果が大きいと入力トークンが雪だるま式に増えます。うちでは1回の探索で数十万トークンに達したケースがありました。対策はプロンプトキャッシュで、履歴の末尾にキャッシュ区切りを毎ターン付け替えると、再送部分が定価の1/10になります。エージェントループを本番運用に載せるなら、キャッシュはセットで実装することをおすすめします。