Claude API のツールランナーでエージェントループを自動化する

Claude API エンジニアリング 8分で読めます

Claude にツールを渡すと、Claude は必要に応じてツールの呼び出しを要求してきます。従来はその要求を受け取り、自分でツールを実行し、結果を tool_result として会話履歴に積み直し、もう一度 API を呼ぶ、という往復を自分で書く必要がありました。ツールランナー(tool runner)は、この往復ループを SDK 側が代わりに回してくれる仕組みです。本記事は Anthropic 公式ドキュメントTool runner (SDK)の非公式日本語解説です。ツールランナーは 2026 年 8 月時点でベータ機能です。

ツールランナーとは何か

ツールランナーは、いわゆるエージェントループを SDK に肩代わりさせるヘルパーです。公式ドキュメントは、ツールランナーが自動で行うこととして次の4点を挙げています。

手で書く場合、この4つはすべて自分の責任になります。とくに3番目の会話状態の管理は間違えやすいところで、アシスタントメッセージを積み忘れる、ツール結果の tool_use_id を取り違える、といった不具合が起きます。ツールランナーはここを定型処理として引き受けます。

逆に、使わないほうがよい場面も公式に明記されています。人間による承認(human-in-the-loop)を挟みたい、独自のログを取りたい、条件によってツールの実行可否を変えたい、という場合は手動ループのほうが適切です。ツールランナーは「Claude が呼んだら実行する」を前提に組まれているためです。ただし後述するとおり、ループの途中に割り込んで状態を書き換える余地は用意されています。

対応 SDK は Python、TypeScript、C#、Go、Java、PHP、Ruby の7つです。すべてベータ扱いで、メソッド名や API の形は言語ごとに違います。本記事では Python と TypeScript を中心に扱います。

基本の書き方

Python では @beta_tool デコレータで関数をツールとして宣言し、それを client.beta.messages.tool_runner() に渡します。JSON スキーマを自分で書く必要はありません。デコレータが関数の引数の型ヒントと docstring を読み取ってスキーマを生成します。

import json
from anthropic import Anthropic, beta_tool

client = Anthropic()


@beta_tool
def get_weather(location: str, unit: str = "fahrenheit") -> str:
    """Get the current weather in a given location.

    Args:
        location: The city and state, e.g. San Francisco, CA
        unit: Temperature unit, either 'celsius' or 'fahrenheit'
    """
    return json.dumps({"temperature": "20°C", "condition": "Sunny"})


@beta_tool
def calculate_sum(a: int, b: int) -> str:
    """Add two numbers together.

    Args:
        a: First number
        b: Second number
    """
    return str(a + b)


runner = client.beta.messages.tool_runner(
    model="claude-opus-5",
    max_tokens=1024,
    tools=[get_weather, calculate_sum],
    messages=[
        {
            "role": "user",
            "content": "What's the weather like in Paris? Also, what's 15 + 27?",
        }
    ],
)
for message in runner:
    print(message)

非同期クライアントを使う場合は、@beta_tool@beta_async_tool に置き換え、関数を async def で定義します。

戻り値の型に注意してください。ツールが返せるのは文字列か、コンテンツブロック(text / image / document)です。文字列を返すと、それが1つのテキストブロックになります。JSON オブジェクトや数値のような構造化データを返したいときは、先に文字列へエンコードする必要があります。上のコードで json.dumps()str() を挟んでいるのはこのためです。画像やドキュメントのブロックを返せるので、ツールの結果をマルチモーダルにすることもできます。

TypeScript には2つの書き方があります。推奨は Zod を使う betaZodTool() で、Zod 3.25.0 以上が必要です。

import Anthropic from "@anthropic-ai/sdk";
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";

const client = new Anthropic();

const getWeatherTool = betaZodTool({
  name: "get_weather",
  description: "Get the current weather in a given location",
  inputSchema: z.object({
    location: z.string().describe("The city and state, e.g. San Francisco, CA"),
    unit: z.enum(["celsius", "fahrenheit"]).default("fahrenheit").describe("Temperature unit")
  }),
  run: async (input) => {
    return JSON.stringify({ temperature: "20°C", condition: "Sunny" });
  }
});

const finalMessage = await client.beta.messages.toolRunner({
  model: "claude-opus-5",
  max_tokens: 1024,
  tools: [getWeatherTool],
  messages: [{ role: "user", content: "What's the weather like in Paris?" }]
});

Zod を使わず JSON スキーマを直接書く betaTool() もあります。ただしこちらは Claude が生成した入力を実行時に検証しません。検証が必要なら run 関数の中で自分で行ってください。この差はセキュリティにも効くので、外部入力を扱うツールでは Zod 版を選ぶ理由になります。

ループの回り方と途中介入

ツールランナーはイテラブルで、Claude からのメッセージを1件ずつ返します。各イテレーションで、ランナーは Claude がツール呼び出しを要求したかどうかを見ます。要求していればツールを実行し、結果を自動で Claude に送り返し、次のメッセージを返します。

ループが終わる条件は2つです。Claude がツール呼び出しを含まないメッセージを返したときか、max_iterations に達したときです。max_iterations は7つの SDK すべてが対応しています。ループの途中で break して抜けることもできます。

途中のメッセージが不要で、最終結果だけほしい場合は次のようにします。

# Python
final_message = runner.until_done()
for block in final_message.content:
    if block.type == "text":
        print(block.text)
// TypeScript: runner を await するだけ
const finalMessage = await runner;

言語ごとに名前が違います。C# は runner.RunUntilDoneAsync()、Go は runner.RunToCompletion(ctx) です。Java にはこの近道がありませんので、最後まで反復して最後のメッセージを保持する書き方になります。

より踏み込んだ制御として、会話履歴を自分で引き取ることができます。1回の応答を捨てて送り直したい(リトライ)、フォローアップのメッセージを差し込みたい、ツール結果を自分で組み立てたい、といった場面のための仕組みです。

Python では、ループ本体の中で runner.generate_tool_call_response() でツール結果を取得し、runner.append_messages() を呼びます。append_messages() を呼んだ時点で「履歴は自分が管理する」という宣言になり、そのイテレーションではランナーの自動追記が行われません。

runner = client.beta.messages.tool_runner(
    model="claude-opus-5",
    max_tokens=1024,
    max_iterations=10,
    tools=[get_weather],
    messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
)

for message in runner:
    tool_response = runner.generate_tool_call_response()
    if tool_response is not None:
        runner.append_messages(
            message,
            tool_response,
            {"role": "user", "content": "Please be concise."},
        )

ここで自分の責任になる点が3つあります。公式ドキュメントが明示している注意です。

  1. そのターンを会話として成立させたいなら、アシスタントメッセージとツール結果を自分で append する
  2. ツール呼び出しが無いときにループが正しく終われるよう、状態の書き換えを条件付きにする
  3. 暴走を防ぐため max_iterations を必ず渡す

2番目を怠ると、ループが終わらなくなります。毎回履歴を書き換えていると、ランナーは「ツール呼び出しが無いから終了」という判断に到達できないためです。

同じ仕組みを使って、ツール結果を送る前に加工することもできます。代表的な用途は、ツール結果に cache_control を付けてプロンプトキャッシュを効かせることです。

エラーの扱いとデバッグ

まず既定の動作を押さえます。ツールが例外を投げると、ツールランナーはそれを捕捉し、is_error: true を付けたツール結果として Claude に返します。プロセスは落ちません。Claude は「そのツールは失敗した」という情報を受け取って、別の手を試したり、ユーザーに報告したりできます。

ツール結果に載るのは例外のメッセージだけ(Python では例外の型とメッセージ)で、スタックトレースは含まれません。

ログの挙動は言語ごとにかなり違うので、ここは実務で効きます。

# info レベルで記録
export ANTHROPIC_LOG=info

# より詳細な debug レベル
export ANTHROPIC_LOG=debug

Python 以外の SDK は、失敗したツールを一切ログに残しません。したがって Go や C# でツールが落ちる理由を知りたいときは、ツール関数の中で例外を捕捉して自分でログを出してから、返すか再送出する必要があります。これを知らないと「Claude が変な答えを返すが原因が分からない」という状態に陥ります。

エラーを Claude に渡さず、自分で処理したい場合もあります。Python と TypeScript では、ツール結果を Claude に送る前に傍受できます。

for message in runner:
    tool_response = runner.generate_tool_call_response()

    if tool_response is not None:
        for block in tool_response["content"]:
            if block.get("is_error"):
                # 例外を投げてループを止める
                raise RuntimeError(f"Tool failed: {json.dumps(block['content'])}")
                # あるいはログだけ出して Claude に任せる
                # logger.error(f"Tool error: {json.dumps(block['content'])}")

    print(message.content)

TypeScript では runner.generateToolResponse() が同じ役割を担います。この傍受フックを持つのは Python と TypeScript だけです。たとえば C# には同等のフックが無く、代わりにツール本体から BetaToolError を投げると、ランナーがそれを is_error: true のツール結果に変換します。エラー内容を制御したい場合はこちらを使います。

長時間動くエージェントの文脈管理についても、注意点が1つあります。TypeScript と Ruby のツールランナーにはクライアント側の自動コンパクションがありましたが、どちらも非推奨になりました。現在は context_management リクエストパラメータによるサーバー側コンパクションが推奨で、こちらは全 SDK のツールランナーで動きます。Python SDK(v1.0 以降)と Go / Java / C# / PHP のツールランナーには、そもそもクライアント側コンパクションが入っていません。

ストリーミングと使い分け

ターンごとの応答を逐次処理したい場合は、ストリーミングを有効にします。Python では stream=True を渡すと、各イテレーションが BetaMessageStream オブジェクトを返すようになります。メッセージそのものではなくストリームが返る点が変わります。

runner = client.beta.messages.tool_runner(
    model="claude-opus-5",
    max_tokens=1024,
    tools=[calculate_sum],
    messages=[{"role": "user", "content": "What is 15 + 27?"}],
    stream=True,
)

for message_stream in runner:
    for event in message_stream:
        print("event:", event)
    print("message:", message_stream.get_final_message())

print(runner.until_done())

累積されたメッセージは get_final_message() で取得します。

最後に使い分けを整理します。

ツールランナーはベータ機能です。メソッド名や引数は SDK のバージョンで変わりうるため、実装前に使用言語の SDK リポジトリのドキュメント(Python の tools.mdTypeScript の helpers.md)を確認してください。手動ループとの比較は公式のHandle tool callsが詳しいです。