このガイドでは、GitHub リポジトリを調査する AI エージェントを構築します。「オープンソースの LLM プロジェクトを比較する」のようなタスクを与えると、次を行います。
- 関連するリポジトリを GitHub で検索します
- それぞれについて詳細(スター、フォーク、アクティビティ)を取得します
- 分析して比較します
- 推奨を返します
各 LLM 呼び出しとツール呼び出しは step になります。自己完結し、個別にリトライできる作業単位です。いずれかのステップが失敗すると、Workflows が自動でリトライします。タスクの途中で Workflow 全体がクラッシュしても、最後に成功したステップから再開します。
| 課題 | Workflows での解決策 |
|---|---|
| 長時間続くエージェントループ | 中断しても継続する耐久実行 |
| 不安定な LLM と API 呼び出し | 独立したチェックポイントによる自動リトライ |
| 人の承認を待つ | waitForEvent() で数時間から数日まで一時停止 |
| ジョブ完了のポーリング | リソースを消費せず、確認のあいだに step.sleep() |
このガイドでは、リアルタイムの進捗更新に Agents SDK と Workflows を使い、LLM 呼び出しに Anthropic SDK を使います。同じパターンは、任意の LLM SDK(OpenAI、Google AI、Mistral など)に適用できます。
手順を飛ばして、AI Gateway を使う完成済みエージェントを取得する場合は、次のコマンドを実行します。
npm create cloudflare@latest -- --template cloudflare/docs-examples/workflows/durableAgentCloudflare Workflows に慣れている場合や、先にコードを見たい場合は、この方法を使います。
耐久性のある AI エージェントをゼロから作る手順は、次のとおりです。
- Cloudflare アカウント ↗ に登録します。
Node.js↗ をインストールします。
Node.js のバージョンマネージャー
権限の問題を避け、Node.js のバージョンを切り替えられるよう、Volta ↗ や nvm ↗ などの Node バージョンマネージャーを使います。このガイドの後半で説明する Wrangler には、Node バージョン 16.17.0 以降が必要です。
LLM 呼び出し用の Anthropic API キー ↗ も必要です。新規アカウントには無料クレジットが含まれます。
-
次のコマンドで、新しい Worker プロジェクトを作成します。
npm create cloudflare@latest -- durable-ai-agentyarn create cloudflare durable-ai-agentpnpm create cloudflare@latest durable-ai-agentセットアップでは、次のオプションを選びます。
- What would you like to start with? では、
Hello World exampleを選びます。 - Which template would you like to use? では、
Worker onlyを選びます。 - Which language do you want to use? では、
TypeScriptを選びます。 - Do you want to use git for version control? では、
Yesを選びます。 - Do you want to deploy your application? では、
Noを選びます(デプロイ前にいくつか変更します)。
- What would you like to start with? では、
-
プロジェクトへ移動します。
cd durable-ai-agent -
依存関係をインストールします。
npm install agents @anthropic-ai/sdk
ツールは、LLM が外部システムとやり取りするために呼べる関数です。スキーマ(ツールが受け付ける入力)と実装(実際の処理)を定義します。LLM はタスクに応じて、各ツールをいつ使うかを決めます。
-
補完し合う 2 つのツールを
src/tools.tsに作成します。src/tools.tsts export interface SearchReposInput { query: string; limit?: number; } export interface GetRepoInput { owner: string; repo: string; } interface GitHubSearchResponse { items: Array<{ full_name: string; stargazers_count: number }>; } interface GitHubRepoResponse { full_name: string; description: string; stargazers_count: number; forks_count: number; open_issues_count: number; language: string; license: { name: string } | null; updated_at: string; } export const searchReposTool = { name: "search_repos" as const, description: "Search GitHub repositories by keyword. Returns top results. Use get_repo for details.", input_schema: { type: "object" as const, properties: { query: { type: "string", description: "Search query (e.g., 'typescript orm')", }, limit: { type: "number", description: "Max results (default 5)" }, }, required: ["query"], }, run: async (input: SearchReposInput): Promise<string> => { const response = await fetch( `https://api.github.com/search/repositories?q=${encodeURIComponent(input.query)}&sort=stars&per_page=${input.limit ?? 5}`, { headers: { Accept: "application/vnd.github+json", "User-Agent": "DurableAgent/1.0", }, }, ); if (!response.ok) return `Search failed: ${response.status}`; const data = await response.json<GitHubSearchResponse>(); return JSON.stringify( data.items.map((r) => ({ name: r.full_name, stars: r.stargazers_count, })), ); }, }; export const getRepoTool = { name: "get_repo" as const, description: "Get detailed info about a GitHub repository including stars, forks, and description.", input_schema: { type: "object" as const, properties: { owner: { type: "string", description: "Repository owner (e.g., 'cloudflare')", }, repo: { type: "string", description: "Repository name (e.g., 'workers-sdk')", }, }, required: ["owner", "repo"], }, run: async (input: GetRepoInput): Promise<string> => { const response = await fetch( `https://api.github.com/repos/${input.owner}/${input.repo}`, { headers: { Accept: "application/vnd.github+json", "User-Agent": "DurableAgent/1.0", }, }, ); if (!response.ok) return `Repo not found: ${input.owner}/${input.repo}`; const data = await response.json<GitHubRepoResponse>(); return JSON.stringify({ name: data.full_name, description: data.description, stars: data.stargazers_count, forks: data.forks_count, issues: data.open_issues_count, language: data.language, license: data.license?.name ?? "None", updated: data.updated_at, }); }, }; export const tools = [searchReposTool, getRepoTool];
これらのツールは補完し合います。search_repos がリポジトリを見つけ、get_repo が特定のリポジトリの詳細を取得します。
Agents SDK の AgentWorkflow クラスは、双方向の Agent 通信で Cloudflare Workflows を拡張します。Workflow は進捗を報告し、WebSocket クライアントへブロードキャストし、RPC で Agent のメソッドを呼べます。
stepオブジェクトは、耐久ステップを定義するメソッドを提供します。step.do(name, callback)はコードを実行し、結果を永続化します。Workflow が中断されると、最後に成功したステップから再開します。this.reportProgress()は Agent へ進捗更新を送ります(永続化されません)。this.broadcastToClients()は、接続中のすべての WebSocket クライアントへメッセージを送ります(永続化されません)。
よりやさしい導入は、最初の Workflow を構築する を参照してください。
src/workflow.ts を作成します。
import { AgentWorkflow } from "agents/workflows";
import type { AgentWorkflowEvent, AgentWorkflowStep } from "agents/workflows";
import Anthropic from "@anthropic-ai/sdk";
import {
tools,
searchReposTool,
getRepoTool,
type SearchReposInput,
type GetRepoInput,
} from "./tools";
import type { ResearchAgent } from "./agent";
type Params = { task: string };
export class ResearchWorkflow extends AgentWorkflow<ResearchAgent, Params> {
async run(event: AgentWorkflowEvent<Params>, step: AgentWorkflowStep) {
const client = new Anthropic({ apiKey: this.env.ANTHROPIC_API_KEY });
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: event.payload.task },
];
const toolDefinitions = tools.map(({ run, ...rest }) => rest);
// Durable agent loop - each turn is checkpointed
for (let turn = 0; turn < 10; turn++) {
// Report progress to Agent and connected clients
await this.reportProgress({
step: `llm-turn-${turn}`,
status: "running",
percent: turn / 10,
message: `Processing turn ${turn + 1}...`,
});
const response = (await step.do(
`llm-turn-${turn}`,
{ retries: { limit: 3, delay: "10 seconds", backoff: "exponential" } },
async () => {
const msg = await client.messages.create({
model: "claude-sonnet-4-5-20250929",
max_tokens: 4096,
tools: toolDefinitions,
messages,
});
// Serialize for Workflow state
return JSON.parse(JSON.stringify(msg));
},
)) as Anthropic.Message;
if (!response || !response.content) continue;
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason === "end_turn") {
const textBlock = response.content.find(
(b): b is Anthropic.TextBlock => b.type === "text",
);
const result = {
status: "complete",
turns: turn + 1,
result: textBlock?.text ?? null,
};
// Report completion (durable)
await step.reportComplete(result);
return result;
}
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== "tool_use") continue;
// Broadcast tool execution to clients
this.broadcastToClients({
type: "tool_call",
tool: block.name,
turn,
});
const result = await step.do(
`tool-${turn}-${block.id}`,
{ retries: { limit: 2, delay: "5 seconds" } },
async () => {
switch (block.name) {
case "search_repos":
return searchReposTool.run(block.input as SearchReposInput);
case "get_repo":
return getRepoTool.run(block.input as GetRepoInput);
default:
return `Unknown tool: ${block.name}`;
}
},
);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: result,
});
}
messages.push({ role: "user", content: toolResults });
}
return { status: "max_turns_reached", turns: 10 };
}
}LLM とツールでステップを分ける理由
各 step.do() はチェックポイントを作ります。Workflow がクラッシュしたり、Worker が再起動したりすると、次のようになります。
- LLM ステップのあと: 応答は永続化されます。再開時は LLM 呼び出しを飛ばし、ツール実行へ進みます。
- ツールステップのあと: 結果は永続化されます。あとのツールが失敗しても、先に実行したツールは再実行されません。
これは、とくに次の場合に重要です。
- LLM 呼び出し: 高価で遅いので、不要に繰り返すべきではありません
- 外部 API: レート制限や副作用がある場合があります
- 冪等性: 一部のツール(メール送信など)は 2 回実行すべきではありません
Agent は HTTP リクエスト、WebSocket 接続、Workflow のライフサイクルイベントを扱います。runWorkflow() で workflow インスタンスを起動し、コールバックで進捗更新を受け取ります。
src/agent.ts を作成します。
import { Agent } from "agents";
type State = {
currentWorkflow?: string;
status?: string;
};
export class ResearchAgent extends Agent<Env, State> {
initialState: State = {};
// Start a research task - called via HTTP or WebSocket
async startResearch(task: string) {
const instanceId = await this.runWorkflow("RESEARCH_WORKFLOW", { task });
this.setState({
...this.state,
currentWorkflow: instanceId,
status: "running",
});
return { instanceId };
}
// Get status of a workflow
async getResearchStatus(instanceId: string) {
return this.getWorkflow(instanceId);
}
// Called when workflow reports progress
async onWorkflowProgress(
workflowName: string,
instanceId: string,
progress: unknown,
) {
// Broadcast to all connected WebSocket clients
this.broadcast(JSON.stringify({ type: "progress", instanceId, progress }));
}
// Called when workflow completes
async onWorkflowComplete(
workflowName: string,
instanceId: string,
result?: unknown,
) {
this.setState({ ...this.state, status: "complete" });
this.broadcast(JSON.stringify({ type: "complete", instanceId, result }));
}
// Called when workflow errors
async onWorkflowError(
workflowName: string,
instanceId: string,
error: string,
) {
this.setState({ ...this.state, status: "error" });
this.broadcast(JSON.stringify({ type: "error", instanceId, error }));
}
}-
wrangler.jsoncを開き、Agent と Workflow の設定を追加します。{ "$schema": "node_modules/wrangler/config-schema.json", "name": "durable-ai-agent", "main": "src/index.ts", // Set this to today's date "compatibility_date": "2026-09-20", "observability": { "enabled": true }, "durable_objects": { "bindings": [ { "name": "ResearchAgent", "class_name": "ResearchAgent" } ] }, "workflows": [ { "name": "research-workflow", "binding": "RESEARCH_WORKFLOW", "class_name": "ResearchWorkflow" } ], "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ResearchAgent"] } ] }"$schema" = "node_modules/wrangler/config-schema.json" name = "durable-ai-agent" main = "src/index.ts" # Set this to today's date compatibility_date = "2026-09-20" [observability] enabled = true [[durable_objects.bindings]] name = "ResearchAgent" class_name = "ResearchAgent" [[workflows]] name = "research-workflow" binding = "RESEARCH_WORKFLOW" class_name = "ResearchWorkflow" [[migrations]] tag = "v1" new_sqlite_classes = [ "ResearchAgent" ] -
バインディングの型を生成します。
npx wrangler typesこれにより、バインディングを含む
Env型が入ったworker-configuration.d.tsが作成されます。
Worker はリクエストを Agent へルーティングし、Agent が workflow のライフサイクルを管理します。WebSocket 接続には routeAgentRequest() を、サーバー側の RPC 呼び出しには getAgentByName() を使います。
src/index.ts を置き換えます。
import { getAgentByName, routeAgentRequest } from "agents";
export { ResearchAgent } from "./agent";
export { ResearchWorkflow } from "./workflow";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Route WebSocket connections to /agents/research-agent/{name}
const agentResponse = await routeAgentRequest(request, env);
if (agentResponse) return agentResponse;
// HTTP API for starting research tasks
if (request.method === "POST" && url.pathname === "/research") {
const { task, agentId } = await request.json<{
task: string;
agentId?: string;
}>();
// Get agent instance by name (creates if doesn't exist)
const agent = await getAgentByName(
env.ResearchAgent,
agentId ?? "default",
);
// Start the research workflow via RPC
const result = await agent.startResearch(task);
return Response.json(result);
}
// Check workflow status
if (url.pathname === "/status") {
const instanceId = url.searchParams.get("instanceId");
const agentId = url.searchParams.get("agentId") ?? "default";
if (!instanceId) {
return Response.json({ error: "instanceId required" }, { status: 400 });
}
const agent = await getAgentByName(env.ResearchAgent, agentId);
const status = await agent.getResearchStatus(instanceId);
return Response.json(status);
}
return new Response("POST /research with { task } to start", {
status: 400,
});
},
} satisfies ExportedHandler<Env>;-
ローカル開発用の
.envファイル を作成します。.envsh ANTHROPIC_API_KEY=your-api-key-here -
開発サーバーを起動します。
npx wrangler dev -
調査タスクを開始します。
curl -X POST http://localhost:8787/research \ -H "Content-Type: application/json" \ -d '{"task": "Compare open-source LLM projects"}'{ "instanceId": "abc-123-def" } -
進捗を確認します(完了まで数秒かかることがあります)。
curl "http://localhost:8787/status?instanceId=abc-123-def"
エージェントはリポジトリを検索し、詳細を取得し、比較を返します。進捗更新は、接続中の WebSocket クライアントへブロードキャストされます。
-
Worker をデプロイします。
npx wrangler deploy -
API キーをシークレットとして追加します。
npx wrangler secret put ANTHROPIC_API_KEY -
デプロイした Worker で調査タスクを開始します。
curl -X POST https://durable-ai-agent.<your-subdomain>.workers.dev/research \ -H "Content-Type: application/json" \ -d '{"task": "Compare open-source LLM projects"}' -
CLI で workflow の実行を確認します。
npx wrangler workflows instances describe research-workflow latestエージェントが実行したすべてのステップ(LLM 呼び出し、ツール実行、所要時間、リトライ)が表示されます。
Cloudflare ダッシュボードの research-workflow でも確認できます。
Workflows を開く ↗
WebSocket で Agent に接続すると、リアルタイムの進捗更新を受け取れます。useAgent フックは /agents/{agent-name}/{instance-name} に接続します。
/agents/research-agent/default → ResearchAgent instance "default"
/agents/research-agent/user-123 → ResearchAgent instance "user-123"import { useState } from "react";
import { useAgent } from "agents/react";
function ResearchUI({ agentId = "default" }) {
const [progress, setProgress] = useState(null);
const { state } = useAgent({
agent: "research-agent", // Maps to ResearchAgent class
name: agentId, // Instance name
onMessage: (message) => {
const data = JSON.parse(message.data);
if (data.type === "progress") {
setProgress(data.progress);
}
},
});
return (
<div>
{progress && (
<p>
{progress.message} ({Math.round(progress.percent * 100)}%)
</p>
)}
</div>
);
}Agent のクラス名は、URL 用に自動で kebab-case に変換されます(ResearchAgent → research-agent)。