Skip to content

非公式本サイトは非公式の日本語ドキュメントであり、Cloudflare 公式サイトではありません。最新情報はdevelopers.cloudflare.comをご確認ください。

ヒューマンインザループのナレッジベース更新

最終更新 Markdown で表示Agent セットアップ

このチュートリアルでは、ナレッジベースを検索して追加するエージェントを構築します。各書き込みは人が承認します。エージェントにデータを変更させるのはリスクがあるため、各保存は実行前に承認で一時停止し、誤った保存はロールバックできます。

作るもの

AI Search インスタンスを検索し、インデックスする新しいドキュメントを提案し、それぞれを承認するまで待ち、承認済みの保存を取り消せる Cloudflare Agent です。

前提条件

  1. Cloudflare アカウント に登録します。
  2. Node.js をインストールします。

Node.js のバージョンマネージャー

権限の問題を避け、Node.js のバージョンを切り替えられるよう、Voltanvm などの Node バージョンマネージャーを使います。このガイドの後半で説明する Wrangler には、Node バージョン 16.17.0 以降が必要です。

ほかに必要なものはありません。エージェントは初回実行時に、自身の AI Search インスタンスをプロビジョニングします。

しくみ

エージェントは Code Mode を使います。これは、モデルが各呼び出しを個別に要求する代わりに、ツールを呼ぶ小さなプログラムを書くツール利用パターンです。durable runtime はプログラムが行うすべての呼び出しを記録し、機微な呼び出しの前に人が承認できるよう一時停止し、適用済みの呼び出しを revert で補償できます。その durable な状態は Agent の Durable Object にあり、承認はリクエストとハイバネーションをまたいで待てます。

ランタイムへ AI Search を公開するには connector を使います。AI Search の操作を、モデルが呼べるメソッドに変えるプレーンなクラスです。このチュートリアルでは、読み取り専用の search メソッドと、承認が必要な saveDocument メソッドをモデルに与えます。

1. Worker プロジェクトを作成する

create-cloudflare CLI(C3)で新しい Worker プロジェクトを作成します。C3 は、Cloudflare への新規アプリケーションのセットアップとデプロイを支援するコマンドラインツールです。

次のコマンドで、kb-agent という名前の新しいプロジェクトを作成します。

npm create cloudflare@latest -- kb-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 を選びます(デプロイ前にいくつか変更します)。

アプリケーションディレクトリへ移動します。

cd kb-agent

依存関係をインストールします。aizod のバージョンは、Agents SDK が peer dependencies として想定する範囲にピン留めします。

npm i @cloudflare/codemode @cloudflare/ai-chat agents ai@6 workers-ai-provider zod@4

このチュートリアルは AI Search と Worker Loader のバインディングを使い、Wrangler v4 が必要です。create-cloudflare が以前のバージョンでプロジェクトをセットアップした場合は、アップグレードします。

npm i -D wrangler@4

2. Wrangler を設定する

Wrangler 設定ファイル を次で置き換えます。AI Search バインディング、モデル用の Workers AI バインディング、モデルのコードを隔離された Worker で実行する Worker Loader バインディング、エージェントのチャット履歴と durable runtime 状態を保存する Durable Object を追加します。

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "kb-agent",
  "main": "src/server.ts",
  // Set this to today's date
  "compatibility_date": "2026-09-20",
  "compatibility_flags": [
    "nodejs_compat"
  ],
  "ai": {
    "binding": "AI"
  },
  "ai_search_namespaces": [
    {
      "binding": "AI_SEARCH",
      "namespace": "default",
      "remote": true
    }
  ],
  "worker_loaders": [
    {
      "binding": "LOADER"
    }
  ],
  "durable_objects": {
    "bindings": [
      {
        "name": "Chat",
        "class_name": "Chat"
      }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      "new_sqlite_classes": [
        "Chat"
      ]
    }
  ]
}
name = "kb-agent"
main = "src/server.ts"
# Set this to today's date
compatibility_date = "2026-09-20"
compatibility_flags = ["nodejs_compat"]

[ai]
binding = "AI"

[[ai_search_namespaces]]
binding = "AI_SEARCH"
namespace = "default"
remote = true

[[worker_loaders]]
binding = "LOADER"

[[durable_objects.bindings]]
name = "Chat"
class_name = "Chat"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["Chat"]

AI Search にはローカルエミュレーターがないため、バインディングは常にリモートサービス(remote = true)と通信します。そのため、エージェントは wrangler dev ではなくデプロイして動かします。AIChatAgent はメッセージを SQLite に永続化するため、そのクラスは new_sqlite_classes に載せる必要があります。

3. AI Search connector を作成する

src/ai-search-connector.ts を作成します。connector は AI Search バインディングを直接呼ぶため、リクエストはプロセス内に留まり、公開エンドポイントは不要です。

モデルに読み取り専用の search メソッドと saveDocument メソッドを与えます。saveDocument はコンテンツを書き込むため、requiresApproval を付け、ランタイムがロールバックできるよう revert を追加します。

src/ai-search-connector.jsjs
import { CodemodeConnector } from "@cloudflare/codemode";

// The instance this connector reads from and writes to.
const INSTANCE_NAME = "knowledge-base";

// A connector turns AI Search operations into methods the model can call from
// its generated code. Each connector becomes one named object in the sandbox.
export class AISearchConnector extends CodemodeConnector {
	// The sandbox global. The model calls `aiSearch.search()` and
	// `aiSearch.saveDocument()`.
	name() {
		return "aiSearch";
	}

	// Shown to the model so it knows what this connector is for.
	instructions() {
		return "Use this connector to search indexed content and save new documents.";
	}

	// Every method the model can call. `inputSchema` is JSON Schema; the runtime
	// validates the model's arguments against it before calling `execute`.
	tools() {
		return {
			// Read-only, so it runs without approval.
			search: {
				description: "Search indexed content and return the matching chunks.",
				inputSchema: {
					type: "object",
					properties: { query: { type: "string" } },
					required: ["query"],
				},
				execute: async (input) => {
					const { query } = input;
					return this.env.AI_SEARCH.get(INSTANCE_NAME).search({
						query,
						ai_search_options: { retrieval: { max_num_results: 5 } },
					});
				},
			},
			// Writes content, so it is gated behind approval and made reversible.
			saveDocument: {
				description: "Save a new document to the knowledge base.",
				inputSchema: {
					type: "object",
					properties: {
						name: { type: "string" },
						content: { type: "string" },
					},
					required: ["name", "content"],
				},
				// Pauses the run before this method executes so a human can approve it.
				requiresApproval: true,
				execute: async (input) => {
					const { name, content } = input;
					// upload() queues the document for indexing and returns right away.
					// The item becomes searchable once indexing finishes, a few seconds later.
					const item = await this.env.AI_SEARCH.get(INSTANCE_NAME).items.upload(
						name,
						content,
					);
					// This return value is passed to `revert` if the call is rolled back.
					return { id: item.id, key: item.key, status: item.status };
				},
				// Compensating action for rollback: delete the document this call added.
				revert: async (_input, result) => {
					const { id } = result;
					await this.env.AI_SEARCH.get(INSTANCE_NAME).items.delete(id);
				},
			},
		};
	}
}
src/ai-search-connector.tsts
import { CodemodeConnector, type ConnectorTools } from "@cloudflare/codemode";

// The instance this connector reads from and writes to.
const INSTANCE_NAME = "knowledge-base";

// A connector turns AI Search operations into methods the model can call from
// its generated code. Each connector becomes one named object in the sandbox.
export class AISearchConnector extends CodemodeConnector<Env> {
	// The sandbox global. The model calls `aiSearch.search()` and
	// `aiSearch.saveDocument()`.
	override name() {
		return "aiSearch";
	}

	// Shown to the model so it knows what this connector is for.
	protected override instructions() {
		return "Use this connector to search indexed content and save new documents.";
	}

	// Every method the model can call. `inputSchema` is JSON Schema; the runtime
	// validates the model's arguments against it before calling `execute`.
	protected override tools(): ConnectorTools {
		return {
			// Read-only, so it runs without approval.
			search: {
				description: "Search indexed content and return the matching chunks.",
				inputSchema: {
					type: "object",
					properties: { query: { type: "string" } },
					required: ["query"],
				},
				execute: async (input) => {
					const { query } = input as { query: string };
					return this.env.AI_SEARCH.get(INSTANCE_NAME).search({
						query,
						ai_search_options: { retrieval: { max_num_results: 5 } },
					});
				},
			},
			// Writes content, so it is gated behind approval and made reversible.
			saveDocument: {
				description: "Save a new document to the knowledge base.",
				inputSchema: {
					type: "object",
					properties: {
						name: { type: "string" },
						content: { type: "string" },
					},
					required: ["name", "content"],
				},
				// Pauses the run before this method executes so a human can approve it.
				requiresApproval: true,
				execute: async (input) => {
					const { name, content } = input as { name: string; content: string };
					// upload() queues the document for indexing and returns right away.
					// The item becomes searchable once indexing finishes, a few seconds later.
					const item = await this.env.AI_SEARCH.get(INSTANCE_NAME).items.upload(
						name,
						content,
					);
					// This return value is passed to `revert` if the call is rolled back.
					return { id: item.id, key: item.key, status: item.status };
				},
				// Compensating action for rollback: delete the document this call added.
				revert: async (_input, result) => {
					const { id } = result as { id: string };
					await this.env.AI_SEARCH.get(INSTANCE_NAME).items.delete(id);
				},
			},
		};
	}
}

name() の結果(aiSearch)は、モデルのコードが呼ぶグローバルになります。メソッドは aiSearch.search()aiSearch.saveDocument() として使えます。

4. エージェントを構築する

src/server.ts を作成します。エージェントは初回実行時に hybrid search を有効にした AI Search インスタンスをプロビジョニングし、connector 付きの Code Mode runtime を作成して、モデルへ単一の codemode ツールとして公開します。@callable() メソッドにより、クライアントは保留中の承認を一覧し、書き込みを承認、拒否、またはロールバックできます。

src/server.jsjs
import { AIChatAgent } from "@cloudflare/ai-chat";
import {
	createCodemodeRuntime,
	DynamicWorkerExecutor,
} from "@cloudflare/codemode";
import { callable, routeAgentRequest } from "agents";
import { createWorkersAI } from "workers-ai-provider";
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import { AISearchConnector } from "./ai-search-connector";

// Code Mode stores its durable state (execution log, pending approvals) in a
// facet exported from the Worker entry. The runtime requires this export.
export { CodemodeRuntime } from "@cloudflare/codemode";

const INSTANCE_NAME = "knowledge-base";

// Seed content, so the agent has something to find on the first query.
const SEED_DOC = `# Getting started
AI Search indexes your content so an agent can search it and add to it.`;

export class Chat extends AIChatAgent {
	// In-memory guard, so the one-time setup runs once per instance lifetime.
	ready = false;

	// Create the AI Search instance with hybrid search enabled, then seed it.
	// create() throws if the instance already exists, so the try/catch makes
	// this safe to call on every message.
	async ensureInstance() {
		if (this.ready) return;
		try {
			// index_method with both vector and keyword enables hybrid search.
			await this.env.AI_SEARCH.create({
				id: INSTANCE_NAME,
				index_method: { vector: true, keyword: true },
			});
			// Queue the seed document for indexing so the first search has content.
			await this.env.AI_SEARCH.get(INSTANCE_NAME).items.upload(
				"getting-started.md",
				SEED_DOC,
			);
		} catch (err) {
			// create() throws if the instance already exists, which is expected on
			// every run after the first. Log anything else so real failures surface.
			console.error("ensureInstance:", err);
		}
		this.ready = true;
	}

	// Build the Code Mode runtime for this request. The handle is cheap to
	// create; the durable state lives in the Durable Object, not the handle.
	#runtime() {
		return createCodemodeRuntime({
			ctx: this.ctx,
			// Runs the model's generated code in an isolated Worker.
			executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),
			connectors: [new AISearchConnector(this.ctx, this.env)],
		});
	}

	// Runs on every chat message from the client.
	async onChatMessage() {
		await this.ensureInstance();

		const workersai = createWorkersAI({ binding: this.env.AI });

		const result = streamText({
			model: workersai("@cf/zai-org/glm-5.2"),
			system:
				"You help maintain a knowledge base. Use codemode to search existing " +
				"content and to save new documents. Search before you answer.",
			messages: await convertToModelMessages(this.messages),
			// The model sees one `codemode` tool and writes code that calls the connector.
			tools: { codemode: this.#runtime().tool() },
			// Cap the agent's tool-use loop.
			stopWhen: stepCountIs(10),
		});

		return result.toUIMessageStreamResponse();
	}

	// The methods below are called from your client to drive the approval flow.

	// List the writes that are paused waiting for approval.
	@callable()
	async pendingApprovals() {
		return this.#runtime().pending();
	}

	// Approve a paused write. The runtime resumes the program and runs it.
	@callable()
	async approveExecution(executionId) {
		return this.#runtime().approve({ executionId });
	}

	// Decline a paused write. The execution ends without saving.
	@callable()
	async rejectExecution(executionId, seq) {
		return this.#runtime().reject({ executionId, seq });
	}

	// Undo an applied write by running the connector's revert.
	@callable()
	async rollbackExecution(executionId) {
		await this.#runtime().rollback({ executionId });
	}
}

export default {
	async fetch(request, env) {
		return (
			(await routeAgentRequest(request, env)) ||
			new Response("Not found", { status: 404 })
		);
	},
};
src/server.tsts
import { AIChatAgent } from "@cloudflare/ai-chat";
import {
	createCodemodeRuntime,
	DynamicWorkerExecutor,
	type CodemodeRuntimeHandle,
	type PendingAction,
} from "@cloudflare/codemode";
import { callable, routeAgentRequest } from "agents";
import { createWorkersAI } from "workers-ai-provider";
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import { AISearchConnector } from "./ai-search-connector";

// Code Mode stores its durable state (execution log, pending approvals) in a
// facet exported from the Worker entry. The runtime requires this export.
export { CodemodeRuntime } from "@cloudflare/codemode";

const INSTANCE_NAME = "knowledge-base";

// Seed content, so the agent has something to find on the first query.
const SEED_DOC = `# Getting started
AI Search indexes your content so an agent can search it and add to it.`;

export class Chat extends AIChatAgent<Env> {
	// In-memory guard, so the one-time setup runs once per instance lifetime.
	private ready = false;

	// Create the AI Search instance with hybrid search enabled, then seed it.
	// create() throws if the instance already exists, so the try/catch makes
	// this safe to call on every message.
	private async ensureInstance() {
		if (this.ready) return;
		try {
			// index_method with both vector and keyword enables hybrid search.
			await this.env.AI_SEARCH.create({
				id: INSTANCE_NAME,
				index_method: { vector: true, keyword: true },
			});
			// Queue the seed document for indexing so the first search has content.
			await this.env.AI_SEARCH.get(INSTANCE_NAME).items.upload(
				"getting-started.md",
				SEED_DOC,
			);
		} catch (err) {
			// create() throws if the instance already exists, which is expected on
			// every run after the first. Log anything else so real failures surface.
			console.error("ensureInstance:", err);
		}
		this.ready = true;
	}

	// Build the Code Mode runtime for this request. The handle is cheap to
	// create; the durable state lives in the Durable Object, not the handle.
	#runtime(): CodemodeRuntimeHandle {
		return createCodemodeRuntime({
			ctx: this.ctx,
			// Runs the model's generated code in an isolated Worker.
			executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),
			connectors: [new AISearchConnector(this.ctx, this.env)],
		});
	}

	// Runs on every chat message from the client.
	async onChatMessage() {
		await this.ensureInstance();

		const workersai = createWorkersAI({ binding: this.env.AI });

		const result = streamText({
			model: workersai("@cf/zai-org/glm-5.2"),
			system:
				"You help maintain a knowledge base. Use codemode to search existing " +
				"content and to save new documents. Search before you answer.",
			messages: await convertToModelMessages(this.messages),
			// The model sees one `codemode` tool and writes code that calls the connector.
			tools: { codemode: this.#runtime().tool() },
			// Cap the agent's tool-use loop.
			stopWhen: stepCountIs(10),
		});

		return result.toUIMessageStreamResponse();
	}

	// The methods below are called from your client to drive the approval flow.

	// List the writes that are paused waiting for approval.
	@callable()
	async pendingApprovals(): Promise<PendingAction[]> {
		return this.#runtime().pending();
	}

	// Approve a paused write. The runtime resumes the program and runs it.
	@callable()
	async approveExecution(executionId: string) {
		return this.#runtime().approve({ executionId });
	}

	// Decline a paused write. The execution ends without saving.
	@callable()
	async rejectExecution(executionId: string, seq: number): Promise<boolean> {
		return this.#runtime().reject({ executionId, seq });
	}

	// Undo an applied write by running the connector's revert.
	@callable()
	async rollbackExecution(executionId: string): Promise<void> {
		await this.#runtime().rollback({ executionId });
	}
}

export default {
	async fetch(request: Request, env: Env) {
		return (
			(await routeAgentRequest(request, env)) ||
			new Response("Not found", { status: 404 })
		);
	},
} satisfies ExportedHandler<Env>;

型を生成します。

npx wrangler types

5. デプロイする

AI Search はリモートで動くため、エージェントを動かすには Worker をデプロイします。

Cloudflare アカウントでログインします。

npx wrangler login

Worker をデプロイし、インターネットからアクセスできるようにします。

npx wrangler deploy

Wrangler は Worker の URL を出力します。たとえば https://kb-agent.<your-subdomain>.workers.dev です。次のステップで使います。

6. 承認とロールバックのフローを試す

モデルは 1 つの codemode ツールを受け取ります。コンテンツの検索と保存を依頼すると、connector メソッドを呼ぶ短いプログラムを書きます。

// The model writes this program. It runs inside the Code Mode sandbox.
async () => {
	// The read-only search runs immediately.
	const existing = await aiSearch.search({ query: "onboarding steps" });

	// Only save when the knowledge base has no matching content yet.
	if (existing.chunks.length === 0) {
		// saveDocument requires approval, so this call pauses the program here.
		await aiSearch.saveDocument({
			name: "onboarding.md",
			content: "# Onboarding\nStep 1: create an account.",
		});
	}

	return existing.chunks.length;
};

aiSearch.search() はすぐに実行されます。プログラムが aiSearch.saveDocument() に達すると、ランタイムは呼び出しを pending として記録し、アップロードが走る前に実行を一時停止します。

クライアントは実行を始めるチャットメッセージを送り、その後 @callable() メソッドで承認を進めます。次のスクリプトは Agents SDK クライアント でその両方を行います。client.mjs として保存し、HOST をデプロイ済み Worker に設定して、node client.mjs で実行します。

client.mjsjs
import { AgentClient } from "agents/client";

// Your deployed Worker, without the protocol.
const HOST = "kb-agent.<your-subdomain>.workers.dev";

const client = new AgentClient({ agent: "Chat", name: "default", host: HOST });
await client.ready;

// 1. Ask the agent to find and save content. It writes a Code Mode program;
//    the saveDocument call pauses for approval instead of running.
client.send(
	JSON.stringify({
		type: "cf_agent_use_chat_request",
		id: crypto.randomUUID(),
		init: {
			method: "POST",
			body: JSON.stringify({
				messages: [
					{
						id: crypto.randomUUID(),
						role: "user",
						parts: [
							{
								type: "text",
								text: "Search for onboarding steps. If there is none, save a document named onboarding.md with a short onboarding guide.",
							},
						],
					},
				],
			}),
		},
	}),
);

// Give the turn time to run and pause at saveDocument.
await new Promise((r) => setTimeout(r, 20_000));

// 2. List the writes waiting for approval.
const pending = await client.call("pendingApprovals");
console.log("Pending approvals:", pending);

// 3. Approve the first one. The runtime replays the program: completed calls
//    return their recorded results, and the approved saveDocument runs.
if (pending.length > 0) {
	await client.call("approveExecution", [pending[0].executionId]);
	console.log("Approved", pending[0].executionId);

	// To roll back later, delete the uploaded document:
	// await client.call("rollbackExecution", [pending[0].executionId]);
}

client.close();
client.mjsts
import { AgentClient } from "agents/client";

// Your deployed Worker, without the protocol.
const HOST = "kb-agent.<your-subdomain>.workers.dev";

const client = new AgentClient({ agent: "Chat", name: "default", host: HOST });
await client.ready;

// 1. Ask the agent to find and save content. It writes a Code Mode program;
//    the saveDocument call pauses for approval instead of running.
client.send(
	JSON.stringify({
		type: "cf_agent_use_chat_request",
		id: crypto.randomUUID(),
		init: {
			method: "POST",
			body: JSON.stringify({
				messages: [
					{
						id: crypto.randomUUID(),
						role: "user",
						parts: [
							{
								type: "text",
								text: "Search for onboarding steps. If there is none, save a document named onboarding.md with a short onboarding guide.",
							},
						],
					},
				],
			}),
		},
	}),
);

// Give the turn time to run and pause at saveDocument.
await new Promise((r) => setTimeout(r, 20_000));

// 2. List the writes waiting for approval.
const pending = await client.call("pendingApprovals");
console.log("Pending approvals:", pending);

// 3. Approve the first one. The runtime replays the program: completed calls
//    return their recorded results, and the approved saveDocument runs.
if (pending.length > 0) {
	await client.call("approveExecution", [pending[0].executionId]);
	console.log("Approved", pending[0].executionId);

	// To roll back later, delete the uploaded document:
	// await client.call("rollbackExecution", [pending[0].executionId]);
}

client.close();

pendingApprovals() からの各 PendingAction には executionIdseq 番号、メソッドと引数が含まれるため、決める前に保留中のドキュメントをユーザーに見せられます。承認メソッドの動作は次のとおりです。

  • approveExecution(executionId) はプログラムを再生し、承認された saveDocument を実行します。ドキュメントはインデックス待ちになり、数秒後に検索可能になります。
  • rejectExecution(executionId, seq) は保存せずに実行を終了します。
  • rollbackExecution(executionId) は connector の revert を実行して適用済みの書き込みを取り消し、アップロードしたドキュメントを削除します。

構築したもの

エージェントは次ができるようになりました。

  • 読み取り専用ツールでナレッジベースを検索する。
  • 人の承認で一時停止する書き込みツール経由で、新しいドキュメントを提案する。
  • 承認後に同じプログラムを再開し、完了済みの作業を再実行しない。
  • インデックス済みドキュメントを削除して、承認済みの保存をロールバックする。

次のステップ

Hybrid search

設定可能な fusion で、ベクトル検索とキーワード検索を組み合わせます。

Items Workers binding

ドキュメントのアップロード、一覧、削除の完全なリファレンスです。

役に立ちましたか?