CodeCraft Lab

Agent SDKでカスタムエージェントを構築する

Agent SDKでのカスタムツール定義、セッションを使った状態管理、エラーハンドリングの実装方法を、簡単なタスク自動化エージェントのサンプルとともに学びます

応用17分で読了
Agent SDKカスタムツール状態管理エラーハンドリング

このレッスンで学ぶこと

  • Agent SDKでカスタムツールを定義し、Claudeから呼び出せるようにする
  • セッション(resume / fork)を使って、複数のやり取りにまたがる状態管理を設計する
  • ツールハンドラー内でのエラーハンドリングを、エージェントループを止めない形で実装する
  • これらを組み合わせた、実践的なタスク自動化エージェントを構築する

前提条件

  • 応用コース Lesson 8(Agent SDKの基礎)を修了し、query()の基本的な使い方を理解していること
  • TypeScriptまたはPythonでの非同期処理(async/await)の基礎知識

カスタムツールを定義する

Lesson 8で使ったallowed_toolsは、Claude Codeに組み込まれているツール(ReadBashなど)を許可するものでした。Agent SDKでは、それに加えて自分で定義したツールをClaudeに与えることができます。これにより、外部API呼び出しやドメイン固有の処理を、Claudeが会話の中で自然に使えるようになります。

ツールは4つの要素で構成されます。

要素役割
名前Claudeがツールを呼び出す際に使う一意の識別子
説明ツールが何をするかの説明。Claudeはこれを読んでいつ呼び出すか判断する
入力スキーマ引数の型定義。TypeScriptはZodスキーマ、Pythonは型の辞書(またはJSON Schema)
ハンドラー実際に呼び出される非同期関数。結果をcontent配列として返す

最小のカスタムツールを作る

天気情報を取得するツールを例に、定義から呼び出しまでの流れを見てみましょう。

import { tool, createSdkMcpServer, query } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
 
// 1. ツールを定義する
const getTemperature = tool(
  "get_temperature",
  "指定した緯度経度の現在の気温を取得する",
  {
    latitude: z.number().describe("緯度"),
    longitude: z.number().describe("経度")
  },
  async (args) => {
    const response = await fetch(
      `https://api.open-meteo.com/v1/forecast?latitude=${args.latitude}&longitude=${args.longitude}&current=temperature_2m`
    );
    const data: any = await response.json();
    return {
      content: [{ type: "text", text: `気温: ${data.current.temperature_2m}°C` }]
    };
  }
);
 
// 2. ツールをMCPサーバーにまとめる
const weatherServer = createSdkMcpServer({
  name: "weather",
  version: "1.0.0",
  tools: [getTemperature]
});
 
// 3. query()に渡して呼び出す
for await (const message of query({
  prompt: "東京の気温を教えて(緯度35.68, 経度139.77)",
  options: {
    mcpServers: { weather: weatherServer },
    allowedTools: ["mcp__weather__get_temperature"]
  }
})) {
  if (message.type === "result" && message.subtype === "success") {
    console.log(message.result);
  }
}
import asyncio
from typing import Any
import httpx
from claude_agent_sdk import tool, create_sdk_mcp_server, query, ClaudeAgentOptions, ResultMessage
 
 
# 1. ツールを定義する
@tool(
    "get_temperature",
    "指定した緯度経度の現在の気温を取得する",
    {"latitude": float, "longitude": float},
)
async def get_temperature(args: dict[str, Any]) -> dict[str, Any]:
    async with httpx.AsyncClient() as client:
        response = await client.get(
            "https://api.open-meteo.com/v1/forecast",
            params={"latitude": args["latitude"], "longitude": args["longitude"], "current": "temperature_2m"},
        )
        data = response.json()
    return {"content": [{"type": "text", "text": f"気温: {data['current']['temperature_2m']}°C"}]}
 
 
# 2. ツールをMCPサーバーにまとめる
weather_server = create_sdk_mcp_server(name="weather", version="1.0.0", tools=[get_temperature])
 
 
async def main():
    options = ClaudeAgentOptions(
        mcp_servers={"weather": weather_server},
        allowed_tools=["mcp__weather__get_temperature"],
    )
    # 3. query()に渡して呼び出す
    async for message in query(prompt="東京の気温を教えて(緯度35.68, 経度139.77)", options=options):
        if isinstance(message, ResultMessage) and message.subtype == "success":
            print(message.result)
 
 
asyncio.run(main())

呼び出し可能なツール名はmcp__{サーバー名}__{ツール名}という形式になります。今回の例ではmcp__weather__get_temperatureです。この名前をallowedToolsに含めることで、確認プロンプトなしで実行されるようになります。

パラメータをオプションにする

TypeScriptでは、Zodフィールドに.default()を付けるとオプション引数になります。Pythonの辞書スキーマはすべてのキーが必須として扱われるため、オプションにしたい引数はスキーマから除外し、説明文でその存在に触れたうえで、ハンドラー内でargs.get("hours", 12)のように取得します。

状態管理の設計パターン

Agent SDKでの「状態管理」は、大きく2つのレイヤーに分けて考えると整理しやすくなります。1つは会話の文脈(セッション)、もう1つはツールが扱うアプリケーション固有のデータです。

セッションによる会話状態の管理

1回限りのタスクならquery()を1回呼ぶだけで完結しますが、複数ターンにまたがる作業では、前回までの文脈を引き継ぐ必要があります。Agent SDKはこれを「セッション」として扱い、3つの操作で制御します。

操作用途
continue同一プロセス内、または同一ディレクトリ内の最新セッションに追記する
resumeセッションIDを指定して、特定の過去のセッションに戻る
fork過去のセッションから分岐した新しいセッションを作る(元は変更されない)

PythonではClaudeSDKClientを使うと、セッションIDを意識せずに複数ターンの会話を維持できます。

import asyncio
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, AssistantMessage, TextBlock
 
 
async def main():
    options = ClaudeAgentOptions(allowed_tools=["Read", "Glob", "Grep"])
 
    async with ClaudeSDKClient(options=options) as client:
        # 1回目: 調査を依頼
        await client.query("認証モジュールの実装を分析して")
        async for message in client.receive_response():
            if isinstance(message, AssistantMessage):
                for block in message.content:
                    if isinstance(block, TextBlock):
                        print(block.text)
 
        # 2回目: 同じクライアントなので、1回目の文脈をそのまま引き継ぐ
        await client.query("その分析を踏まえて、JWTを使う形にリファクタリングして")
        async for message in client.receive_response():
            if isinstance(message, AssistantMessage):
                for block in message.content:
                    if isinstance(block, TextBlock):
                        print(block.text)
 
 
asyncio.run(main())

TypeScriptにはClaudeSDKClientに相当する常駐クライアントはなく、continue: trueオプションで「ディレクトリ内の最新セッションを再開する」という形で同等のことを行います。

特定の過去のセッションに戻りたい場合(最新ではないセッション、あるいは複数ユーザーの会話を並行管理する場合など)は、ResultMessagesession_idを控えておき、resumeオプションに渡します。

let sessionId: string | undefined;
 
for await (const message of query({
  prompt: "認証モジュールを分析して",
  options: { allowedTools: ["Read", "Glob", "Grep"] }
})) {
  if (message.type === "result") {
    sessionId = message.session_id;
  }
}
 
// 後から、保存しておいたsession_idで明示的に再開する
for await (const message of query({
  prompt: "提案したリファクタリングを実装して",
  options: { resume: sessionId, allowedTools: ["Read", "Edit", "Write"] }
})) {
  if (message.type === "result" && message.subtype === "success") {
    console.log(message.result);
  }
}
セッションは会話を保持する。ファイルシステムは別

セッションが保持するのはあくまで会話履歴(プロンプト・ツール呼び出し・応答)です。エージェントが実際に行ったファイル変更はディスク上にそのまま残るため、「会話を巻き戻したのにファイルは変更されたまま」という状態がありえます。ファイル変更そのものを巻き戻したい場合は、別の仕組み(ファイルチェックポイント)が必要になる点を覚えておきましょう。

ツール側のアプリケーション状態管理

会話の文脈とは別に、ツールハンドラーが扱うデータ(DB接続、処理対象のキューなど)をどう持たせるかも設計判断が必要です。シンプルなタスク自動化エージェントであれば、ツール定義のクロージャにモジュールレベルの状態を持たせる方法が手軽です。

// 簡単な状態管理の例: 処理済みタスクIDをメモリ上で記録する
const processedTaskIds = new Set<string>();
 
const processTask = tool(
  "process_task",
  "指定されたタスクIDのタスクを処理する。同じIDは2回処理しない",
  { taskId: z.string() },
  async (args) => {
    if (processedTaskIds.has(args.taskId)) {
      return {
        content: [{ type: "text", text: `タスク ${args.taskId} は処理済みです。スキップしました。` }]
      };
    }
    // ここで実際の処理を行う
    processedTaskIds.add(args.taskId);
    return {
      content: [{ type: "text", text: `タスク ${args.taskId} を処理しました。` }]
    };
  }
);

このように「ツールが何度も呼ばれても安全(冪等)であること」を意識した状態管理は、後述する自動化エージェントの信頼性に直結します。

エラーハンドリングの実装

ツールハンドラーでのエラーの扱い方は、エージェントループを継続させるか停止させるかを直接左右します。

ハンドラーの挙動結果
例外をキャッチせずスローするエージェントループ全体が停止し、query()呼び出しが失敗する
エラーをキャッチしてisError: true(Python: is_error: True)を返すエージェントループは継続し、Claudeはエラーを「ツールの結果」として認識して対応できる

実務上は、ほとんどのケースで後者(isErrorを使う)が望ましい挙動です。外部APIの一時的な障害や不正な入力に対して、Claude自身に「リトライする」「別の方法を試す」「ユーザーに状況を説明する」といった判断をさせられるためです。

const fetchData = tool(
  "fetch_data",
  "指定したエンドポイントからデータを取得する",
  { endpoint: z.string().url() },
  async (args) => {
    try {
      const response = await fetch(args.endpoint);
 
      if (!response.ok) {
        // HTTPエラーはClaudeに「失敗」として伝える。スローはしない
        return {
          content: [{ type: "text", text: `APIエラー: ${response.status} ${response.statusText}` }],
          isError: true
        };
      }
 
      const data = await response.json();
      return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
    } catch (error) {
      // ネットワークエラーなどもここでキャッチし、ループを止めない
      return {
        content: [{ type: "text", text: `データ取得に失敗しました: ${error instanceof Error ? error.message : String(error)}` }],
        isError: true
      };
    }
  }
);
@tool("fetch_data", "指定したエンドポイントからデータを取得する", {"endpoint": str})
async def fetch_data(args: dict[str, Any]) -> dict[str, Any]:
    try:
        async with httpx.AsyncClient() as client:
            response = await client.get(args["endpoint"])
            if response.status_code != 200:
                # HTTPエラーはClaudeに「失敗」として伝える。例外は投げない
                return {
                    "content": [{"type": "text", "text": f"APIエラー: {response.status_code} {response.reason_phrase}"}],
                    "is_error": True,
                }
            data = response.json()
            return {"content": [{"type": "text", "text": json.dumps(data, indent=2)}]}
    except Exception as e:
        # ネットワークエラーなどもここでキャッチし、ループを止めない
        return {
            "content": [{"type": "text", "text": f"データ取得に失敗しました: {str(e)}"}],
            "is_error": True,
        }

CLAUDE.mdの「想定外のエラーも握りつぶさず、適切にログ出力またはリスローする」という方針は、ツールハンドラーにもそのまま当てはまります。isError: trueで返す場合も、エラー内容をログに残したうえでClaudeに伝える、という形にすると運用時の調査がしやすくなります。

実践: 簡単なタスク自動化エージェント

ここまでの要素(カスタムツール・セッション管理・エラーハンドリング)を組み合わせて、「指定したディレクトリ配下のTODOコメントを検出し、GitHub Issueとして登録する」という簡単な自動化エージェントを作ってみます。

import { tool, createSdkMcpServer, query } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
 
// 登録済みのTODOを記録し、重複登録を防ぐ簡易的な状態管理
const registeredTodos = new Set<string>();
 
const createIssue = tool(
  "create_issue",
  "TODOコメントの内容からGitHub Issueを作成する。同じ内容は重複登録しない",
  {
    title: z.string().describe("Issueのタイトル"),
    body: z.string().describe("Issueの本文。ファイルパスと行番号を含める")
  },
  async (args) => {
    const key = `${args.title}::${args.body}`;
    if (registeredTodos.has(key)) {
      return {
        content: [{ type: "text", text: `「${args.title}」はすでに登録済みのためスキップしました。` }]
      };
    }
 
    try {
      // 実際の運用では gh CLI や Octokit などで Issue を作成する
      // ここではダミーの成功レスポンスを返す
      registeredTodos.add(key);
      return {
        content: [{ type: "text", text: `Issueを作成しました: 「${args.title}」` }]
      };
    } catch (error) {
      // Issue作成に失敗してもループを止めず、Claudeに状況を伝える
      return {
        content: [{ type: "text", text: `Issue作成に失敗しました: ${error instanceof Error ? error.message : String(error)}` }],
        isError: true
      };
    }
  }
);
 
const issueServer = createSdkMcpServer({
  name: "issue-tracker",
  version: "1.0.0",
  tools: [createIssue]
});
 
async function main() {
  for await (const message of query({
    prompt: "src/配下のTODOコメントをすべて探し、それぞれについてcreate_issueツールでIssueを作成して。タイトルはTODOの要約、本文にはファイルパスと行番号を含めること。",
    options: {
      mcpServers: { "issue-tracker": issueServer },
      allowedTools: ["Read", "Glob", "Grep", "mcp__issue-tracker__create_issue"]
    }
  })) {
    if (message.type === "result" && message.subtype === "success") {
      console.log(message.result);
    }
  }
}
 
main();

このエージェントは、組み込みツール(Read Glob Grep)でコードベースを調査する能力と、カスタムツール(create_issue)で外部システムに副作用を起こす能力を組み合わせています。registeredTodosによる重複チェックは、エージェントが同じTODOに対してcreate_issueを2回呼んでしまっても安全であるための、簡易的な状態管理の例です。

副作用のあるツールは慎重に許可する

create_issueのように外部に実際の変更を加えるツールをallowedToolsに含める際は、確認なしで実行されることを意味します。CI/CDのように人間のレビューが間に挟まらない環境で動かす場合は、ツール側にレート制限やドライランモードを設けるなど、安全策を講じることをおすすめします。

まとめ

このレッスンでは、Agent SDKでカスタムツールを定義し、セッションを通じた状態管理を設計し、エラーハンドリングをエージェントループに組み込む方法を学びました。

  • カスタムツールは「名前・説明・入力スキーマ・ハンドラー」の4要素で定義し、createSdkMcpServerでまとめてquery()に渡す
  • 状態管理は「会話の文脈(セッション: continue/resume/fork)」と「ツール側のアプリケーション状態」の2レイヤーで考える
  • ツールハンドラーでは例外をスローせずisError: trueで返すことで、エージェントループを止めずにClaudeにエラー対応を委ねられる
  • 組み込みツールとカスタムツールを組み合わせることで、調査から外部システムへの反映までを1つのエージェントで自動化できる

単体のエージェントを構築できるようになったところで、次は複数のエージェントを連携させる設計に進みます。Lesson 10: マルチエージェントパターンでは、調査・実装・レビューのように役割の異なる複数のエージェントを組み合わせる、実践的なパイプライン設計を学びます。