Claude Media
Claude APIのtool useエージェントループを5段階で自作する — is_errorまで

Claude APIのtool useエージェントループを5段階で自作する — is_errorまで

カレンダー操作ツールで、単発のtool_useからstop_reasonのwhileループ、複数ツール、is_error、Tool Runnerへの置き換えまで5段階で組みます。

Claude APIのツール使用エージェントは、stop_reason が "tool_use" である間だけ回る while ループです。ツールを実行して結果を会話履歴に積み、もう一度APIを呼ぶ。この繰り返しが、エージェントの正体です。

ここでは create_calendar_event というカレンダー操作ツールを題材に、ループを5段階(Ring 1〜5)で育てます。各段は単体で動くプログラムで、1段ごとに足す概念は1つだけです。最後にSDKのTool Runnerへ置き換えるところまで進みます。

5段階で足していくもの

手順

Ring 1からRing 5までの流れ

  1. 1

    Ring 1: 単発の往復

    tool_use を1回受け、tool_result を1回返して end_turn を得ます。

  2. 2

    Ring 2: whileループと履歴

    stop_reason が tool_use の間、実行と再送を繰り返します。履歴は1つのリストに積みます。

  3. 3

    Ring 3: 複数ツールと並列呼び出し

    1つの応答に複数の tool_use が入る前提に直し、結果を1つのuserメッセージにまとめて返します。

  4. 4

    Ring 4: is_errorで失敗を返す

    例外を握りつぶさず、is_error: true 付きの tool_result にします。

  5. 5

    Ring 5: Tool Runnerへの置き換え

    ループ・結果の整形・履歴管理をSDKに任せます。

準備はSDKとAPIキーだけです。以下はPython版で、コードはモデルに claude-opus-5-5 を指定します。

pip install anthropic
export ANTHROPIC_API_KEY="sk-ant-..."

Ring 1: tool_useを受けてtool_resultを返す

最小構成は、ツール1つ、userメッセージ1つ、往復1回です。リクエストの tools にツール定義を渡し、Claudeがツールを使うと判断すると、応答は stop_reason: "tool_use" と tool_use ブロックを返します。ブロックにはツール名、一意の id、構造化された input が入ります。

題材のツールは、ネストしたオブジェクト(recurrence)、配列(attendees)、省略可能な項目を持ちます。平らな文字列1つの引数より、実際のツールに近い形です。

tools = [
    {
        "name": "create_calendar_event",
        "description": "Create a calendar event with attendees and optional recurrence.",
        "input_schema": {
            "type": "object",
            "properties": {
                "title": {"type": "string"},
                "start": {"type": "string", "format": "date-time"},
                "end": {"type": "string", "format": "date-time"},
                "attendees": {
                    "type": "array",
                    "items": {"type": "string", "format": "email"},
                },
                "recurrence": {
                    "type": "object",
                    "properties": {
                        "frequency": {"enum": ["daily", "weekly", "monthly"]},
                        "count": {"type": "integer", "minimum": 1},
                    },
                },
            },
            "required": ["title", "start", "end"],
        },
    }
]

1回目のリクエストを送り、応答から tool_use ブロックを探します。テキストブロックが先頭に来ることがあるので、位置を決め打ちせず content を走査します。

response = client.messages.create(
    model="claude-opus-5-5",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "auto", "disable_parallel_tool_use": True},
    messages=[{"role": "user", "content": "Schedule a 30-minute sync with ..."}],
)
tool_use = next(b for b in response.content if b.type == "tool_use")
result = {"event_id": "evt_123", "status": "created"}  # 実際はカレンダーAPIを呼ぶ

結果を返すときは、直前のassistantの応答をそのまま履歴に含め、次のuserメッセージに tool_result を入れます。tool_use_id は tool_use の id と一致させます。

followup = client.messages.create(
    model="claude-opus-5-5",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "auto", "disable_parallel_tool_use": True},
    messages=[
        {"role": "user", "content": "Schedule a 30-minute sync with ..."},
        {"role": "assistant", "content": response.content},
        {"role": "user", "content": [{
            "type": "tool_result",
            "tool_use_id": tool_use.id,
            "content": json.dumps(result),
        }]},
    ],
)

チュートリアルが示す出力例は次のとおりです(動かした実測値ではなく、掲載されているサンプルです)。

stop_reason: tool_use
Tool: create_calendar_event
Input: {'title': 'Sync', 'start': '2026-03-30T10:00:00', 'end': '2026-03-30T10:30:00', 'attendees': ['alice@example.com', 'bob@example.com']}
stop_reason: end_turn
I've scheduled your 30-minute sync with Alice and Bob for Monday, March 30 at 10am.

1回目の stop_reason が tool_use なのは、Claudeがカレンダーの結果を待っているからです。結果を返した2回目は end_turn になり、中身は利用者向けの自然文です。

tool_resultの並べ方には決まりがある

ツール結果を返すメッセージには、守らないと400になる並べ方があります。

  • tool_result は、対応する tool_use を含むassistantメッセージの直後に置く。間に別のメッセージを挟まない
  • 同じuserメッセージ内では、tool_result を先頭に並べ、テキストはその後ろに置く

Ring 1〜4のコードはどちらも満たしています。結果と一緒に補足テキストを送りたくなったら、テキストは必ず tool_result の後ろへ回します。

Ring 2: stop_reasonで回すwhileループ

Ring 1は、Claudeがツールを1回だけ呼ぶ前提でした。実際の依頼は複数回の呼び出しを要することがあります。イベントを作り、確認結果を読み、また次を作る、といった流れです。そこで条件を stop_reason == "tool_use" にした while ループにします。

もう1つの変更は履歴です。リクエストごとに messages を組み立て直さず、1つのリストに追記し続けます。毎回のリクエストが過去の文脈をすべて含む形になります。

def run_tool(name, tool_input):
    if name == "create_calendar_event":
        return {"event_id": "evt_123", "status": "created", "title": tool_input["title"]}
    return {"error": f"Unknown tool: {name}"}
 
messages = [{"role": "user", "content": "Schedule a weekly team standup every Monday ..."}]
response = client.messages.create(
    model="claude-opus-5-5", max_tokens=1024, tools=tools,
    tool_choice={"type": "auto", "disable_parallel_tool_use": True},
    messages=messages,
)
 
while response.stop_reason == "tool_use":
    tool_use = next(b for b in response.content if b.type == "tool_use")
    result = run_tool(tool_use.name, tool_use.input)
 
    messages.append({"role": "assistant", "content": response.content})
    messages.append({"role": "user", "content": [{
        "type": "tool_result",
        "tool_use_id": tool_use.id,
        "content": json.dumps(result),
    }]})
 
    response = client.messages.create(
        model="claude-opus-5-5", max_tokens=1024, tools=tools,
        tool_choice={"type": "auto", "disable_parallel_tool_use": True},
        messages=messages,
    )

ループが何周するかはClaudeの分解の仕方しだいで、コードは事前に知る必要がありません。サンプルの出力は、4週分の定例を設定した旨の1文です。

ループが抜ける条件はtool_use以外すべて

while の条件が偽になるのは、stop_reason が tool_use 以外のときです。end_turn のほか、max_tokens、stop_sequence、refusal でも抜けます。最後の3つは「最終回答が出た」とは限りません。

Ring 2〜4のコードは、抜けた直後に next(block for block in response.content if block.type == "text") でテキストを取り出します。テキストブロックが無い応答では、この next() が StopIteration を投げます。このコードは最小構成なので、本番に持ち込むなら抜けた理由を先に見ます。

if response.stop_reason != "end_turn":
    # max_tokens なら出力が途中で切れている。refusal なら拒否の扱いを決める
    raise RuntimeError(f"unexpected stop_reason: {response.stop_reason}")

refusal を受けた側の設計はClaude Haiku 5.5のstop_reason refusalは呼び出し側で受ける、エラーと stop_reason の切り分けはstop_reasonとエラーの違いで扱っています。

このコードには周回数の上限もありません。モデルがツールを呼び続ける限り while は回るので、実運用では上限を自分で足す設計が考えられます。上のコードの条件を while response.stop_reason == "tool_use" and turns < 20: のようにし、turns += 1 を各周で数える形です。20は例の数字で、依頼の性質に合わせて決めます。

Ring 3: 複数ツールと、1つのuserメッセージへの全結果

エージェントは1つの能力だけでは足りません。2つ目のツール list_calendar_events(指定日の予定一覧)を足し、「予定を確認してから衝突しない時間に会議を入れる」という依頼を投げます。

Ring 2とRing 3のコードで、見落としやすい差が2つあります。

  • tool_choice の disable_parallel_tool_use: True が消えている
  • next(...) で最初の1個だけ取っていた処理が、for block in response.content の全件走査に変わっている

Ring 2が next() で足りていたのは、並列呼び出しを禁止していたからです。禁止を外した状態でRing 2のループを使うと、2個目以降の tool_use に tool_result が返りません。Claudeが複数の独立した呼び出しを1つの応答に詰めてくる場合に備え、全件を処理して全結果を1つのuserメッセージに入れます。

while response.stop_reason == "tool_use":
    tool_results = []
    for block in response.content:
        if block.type == "tool_use":
            result = run_tool(block.name, block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": json.dumps(result),
            })
 
    messages.append({"role": "assistant", "content": response.content})
    messages.append({"role": "user", "content": tool_results})
 
    response = client.messages.create(
        model="claude-opus-5-5", max_tokens=1024, tools=tools, messages=messages,
    )

サンプル出力は、14時から15時の既存予定を見つけ、10時から11時に計画セッションを入れたという内容です。ここでは list_calendar_events が固定の予定1件を返す仮実装になっています。

並列呼び出しそのものを抑えたいときの挙動はdisable_parallel_tool_useの効果はtool_choiceで変わる、並列呼び出しを増やす書き方は並列ツール呼び出しを増やすシステムプロンプトの書き方にあります。

Ring 4: 失敗をis_errorで返す

ツールは失敗します。カレンダーAPIが参加者数の多すぎるイベントを拒否することも、日付が壊れていることもあります。例外でプログラムを落とさず、エラー文を is_error: true 付きの tool_result で返すと、Claudeはそれを読んで入力を直して再試行するか、利用者に確認するか、制約を説明します。

Ring 4では run_tool が、参加者が11人以上なら例外を投げる仕様になっています。未知のツール名も、辞書を返す代わりに例外にします。呼び出し側で try / except を足すだけです。

for block in response.content:
    if block.type == "tool_use":
        try:
            result = run_tool(block.name, block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": json.dumps(result),
            })
        except Exception as exc:
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": str(exc),
                "is_error": True,
            })

成功の結果との差は is_error のフラグだけです。サンプルの依頼は15人宛ての全体会議で、Claudeの最終応答は「1イベント10人までなので、2回に分けるか、優先する10人を教えてほしい」という趣旨になります。

Ring 3の run_tool は未知のツール名に {"error": ...} という辞書を返していました。これは成功の tool_result として送られ、Claudeには失敗のフラグが届きません。Ring 4で例外に変えたのは、その差を is_error に載せるためです。

エラー文はClaudeが次の行動を選べる形で書く

エラー文は、何が起きたかと次に何を試すかを入れると回復しやすくなります。"failed" だけの文字列より、"Rate limit exceeded. Retry after 60 seconds." のように、原因と待ち時間まで書くほうが回復しやすくなります。Ring 4の Too many attendees (max 10) は、原因と上限値が入っているので、Claudeが参加者を絞る提案に進めました。

必須パラメータが欠けたツール呼び出しも、同じ要領で is_error: true の tool_result を返すと、Claudeが欠けた値を補って再試行します。入力不備なら、Claudeは2〜3回直して再試行し、それでも通らなければ謝罪に回ります。スキーマに合う入力を保証したいなら strict: true の厳密なツール使用もあります。is_error の文面設計はtool_resultのis_errorでエラーを正しく伝える書き方、並列呼び出しで一部だけ失敗した場合は並列ツール呼び出しが一部失敗したときのis_errorの返し方で扱っています。

サーバーツール(Web検索など)のエラーは別です。Claudeが透過的に処理するので、クライアントツールのように is_error を自分で返す必要はありません。

Ring 5: Tool Runnerに置き換える

Ring 2〜4で手で書いたのは、APIを呼び、stop_reason を見て、ツールを実行し、結果を積み、繰り返す、という同じ処理でした。Tool Runnerはこれを肩代わりします。ツールを関数として定義して client.beta.messages.tool_runner() に渡し、ループが終わったら最終メッセージを受け取ります。エラーの包み込み、結果の整形、会話の管理は内部で行われます。

Pythonでは @beta_tool デコレーターが、関数のシグネチャから入力スキーマを組み立てます。スキーマの定義が実装のすぐ隣に置かれます。

from anthropic import beta_tool
 
@beta_tool
def create_calendar_event(
    title: str,
    start: str,
    end: str,
    attendees: list[str] | None = None,
    recurrence: dict | None = None,
) -> str:
    """Create a calendar event with attendees and optional recurrence.
 
    Args:
        title: Event title.
        start: Start time in ISO 8601 format.
        end: End time in ISO 8601 format.
        attendees: Email addresses to invite.
        recurrence: Dict with 'frequency' (daily, weekly, monthly) and 'count'.
    """
    if attendees and len(attendees) > 10:
        raise ValueError("Too many attendees (max 10)")
    return json.dumps({"event_id": "evt_123", "status": "created", "title": title})
 
final_message = client.beta.messages.tool_runner(
    model="claude-opus-5-5",
    max_tokens=1024,
    tools=[create_calendar_event, list_calendar_events],
    messages=[{"role": "user", "content": "Check what I have on Monday, ..."}],
).until_done()

出力はRing 3と同じです。違いはコードの量で、行数はRing 3の約半分になり、手動ループが消えて、スキーマが実装の隣に置かれます。名前空間が beta である点は忘れずに確認します。

言語が変わるとメソッド名も変わります。TypeScriptは client.beta.messages.toolRunner()、Goは client.Beta.Messages.NewToolRunner() です。Tool Runner自体の使い方、ToolErrorの扱い、Agent SDKとの使い分けはTool RunnerでAnthropic APIのツール呼び出しループを自動化するにまとめています。

手動ループに戻る場面

Tool Runnerを選べば、Ring 4の try / except も書かずに済みます。ただし、実行の前に人の承認を挟みたい、ログを1呼び出しごとに取りたい、条件しだいで途中で止めたい、といった要件は手動ループのほうが自然です。Ring 2〜4のコードは、そのための土台として残せます。pause_turn が絡むサーバーツール混在のループは、tool_useとpause_turnの違いが判定の整理になります。

自作ループを本番に近づける前の確認点

ここまでのコードは、仮実装のツールを呼ぶ最小形です。自分のツールを載せる前に、次の点を見直しておくと事故が減ります。

  • stop_reason が tool_use と end_turn 以外のときの扱い(max_tokens の打ち切り、refusal)を決める
  • 周回数の上限を設ける
  • 全結果を返す前に、tool_use_id が欠けていないか数える
  • 外部へ書き込むツール(イベント作成など)は、再試行で二重に実行されても困らないかを考える

最後の点は見落としやすい部分です。Ring 4のようにClaudeが入力を変えて再試行する以上、イベント作成のような書き込み系ツールは、同じ呼び出しが2回届いても予定が2件できない作りにしておく必要があります。

まとめ

エージェントループの核は、stop_reason を見て、全 tool_use に結果を返し、履歴を積み続ける3点です。Ring 2の next() が並列禁止に依存していたように、前提が外れる境目を知っておくと、手動ループの不具合を切り分けやすくなります。細かい制御が不要になった時点でTool Runnerへ移し、承認やログが必要な局面だけ手動ループに戻す。この順で進めるのが無理のない道筋です。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. platform.claude.com/docs/en/agents-and-tools/tool-use/build-a-tool-using-agent
  2. platform.claude.com/docs/en/agents-and-tools/tool-use/how-tool-use-works
  3. platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls
この記事を共有:XはてブLinkedIn