検証済みサポートツールを追加する

CloudflareBeginner
オンラインで実践に進む

はじめに

言語モデルは何をすべきか提案できますが、ツールを使うと、サーバー側の特定の操作を要求できます。この境界は通常のチャットより慎重に扱う必要があります。モデルが生成した引数は信頼できない入力であり、形式が正しいリクエストでも、誤ったサポートキューを対象にしたり、新しい作業を上書きしたりする可能性があるためです。

この実験では、1 つの AIChatAgent に、意図的に小さく設計した 2 つのツールを追加します。

  1. lookupSupportCase は、指定した Agent 自身の SQLite ストレージから合成ケースを 1 件読み取ります。
  2. setSupportPriority は、その合成レコードだけを変更します。
  3. Zod スキーマにより、どちらの操作も実行される前に不正な引数を拒否します。
  4. サーバー側のチェックにより、Agent 名、チケット ID、期待するリビジョンを検証します。
  5. 範囲を制限した Workers AI のターンでツールを呼び出せます。また、独立したプローブによって、同じ操作を決定論的に検証できます。

書き込み対象のレコードは合成データであり、破棄可能です。実際のヘルプデスクシステムには接続しません。ここで重要なのは、スキーマ検証が「入力の形式は正しいか」を確認するのに対し、認証とスコープチェックは「この Agent がそのレコードを変更してよいか」を確認する点です。次の実験では、変更を実行する前に人間の承認を必要とする、別の境界を追加します。

用意されている React ページと短時間だけ有効なセッショントークンにより、フロントエンドや認証の定型コードではなく、ツール設計に集中できます。Workers AI の無料割り当ては、アカウント内のほかのアクティビティと共有されます。アカウントに割り当てが残っていない場合は、有料プランを有効にせず停止してください。

このコースに直接進む前に、LabEx を Cloudflare アカウントに接続するを完了してください。 新しい LabEx VM では、それぞれ専用の Wrangler 認証が必要です。コース内の以前の実験も推奨されますが、それらの VM やリソースはここでは再利用されません。

VM を認証し、ツール用 Worker を宣言する

このステップでは、新しい VM を認証し、ツール対応 Agent が使用するリソースを宣言します。

ターミナルを開き、用意されたプロジェクトに移動します。

cd /home/labex/project/validated-support-tools

この VM を認証します。

npx wrangler login

表示されたリンクを開き、専用の学習用アカウントに対して、Wrangler に必要な権限を承認します。その後、ターミナルに戻ります。構造化された結果を確認します。

npx wrangler whoami --json

"loggedIn": true を探し、アカウント名を確認して、そのアカウントの実際の ID をコピーします。一意で、破棄可能な Worker 名とともに保存します。

ACCOUNT_ID="paste-your-confirmed-account-id"
RUN="labex-c11-s05-$(openssl rand -hex 6)"
cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/server.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true },
  "ai": { "binding": "AI", "remote": true },
  "durable_objects": {
    "bindings": [
      { "name": "SupportToolsAgent", "class_name": "SupportToolsAgent" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["SupportToolsAgent"] }
  ]
}
JSON
python3 .labex/verify.py authorization

AI バインディングにより、API キーを埋め込まずにモデル推論を利用できます。Durable Object バインディングにより、名前を付けた SupportToolsAgent ごとに専用の SQLite ストレージが用意されます。ブラウザーでは planning という名前を使用します。別の名前を使うと別のインスタンスになり、planning のデータは参照できません。まだ何もデプロイされていません。

ツールの契約を定義する

このステップでは、各ツールが受け付ける引数を正確に記述します。

ツールスキーマは、実行時に適用される契約です。TypeScript の型はコンパイル中に役立ちますが、モデルの出力は実行時に到着するため、再度チェックする必要があります。src/cases.ts を作成します。

cat > src/cases.ts <<'TS'
import { z } from "zod";

const queue = z.string()
  .min(3)
  .max(40)
  .regex(/^[a-z0-9-]+$/, "queue must use lowercase letters, digits or hyphens");

export const lookupCaseInput = z.object({
  queue,
  ticketId: z.literal("T-SYNTH-101")
}).strict();

export const updatePriorityInput = lookupCaseInput.extend({
  priority: z.enum(["low", "medium", "high"]),
  expectedRevision: z.number().int().nonnegative()
}).strict();

export type LookupCaseInput = z.infer<typeof lookupCaseInput>;
export type UpdatePriorityInput = z.infer<typeof updatePriorityInput>;
export type SupportCase = {
  queue: string;
  ticketId: "T-SYNTH-101";
  summary: string;
  priority: "low" | "medium" | "high";
  revision: number;
};

export function parseInput<T>(schema: z.ZodType<T>, input: unknown): T {
  const result = schema.safeParse(input);
  if (!result.success) {
    const issue = result.error.issues[0];
    throw new Error(`invalid tool input: ${issue.path.join(".") || "request"} ${issue.message}`);
  }
  return result.data;
}
TS
python3 .labex/verify.py schemas

読み取り用の契約では、有効なキュー名と 1 件の合成チケットだけを受け付けます。更新用の契約では、列挙型の優先度と、0 以上の整数であるリビジョンが追加されます。.strict() により、想定外のフィールドも拒否されます。これによって曖昧さを減らし、呼び出し元がサポート対象外の指示を操作に紛れ込ませることを防ぎます。

expectedRevision楽観的同時実行制御のチェックです。呼び出し元は自分が確認したバージョンを指定し、誰かがすでにそのバージョンを変更していれば、サーバーは更新を拒否します。検証だけでアクセスが許可されるわけではありません。Agent は別途、queue と自身の Durable Object 名を比較します。

スコープ付きのサーバー側ツールを実装する

このステップでは、両方のスキーマを 1 つの Agent ローカルレコードに接続し、モデルと決定論的な検証ツールの両方から同じ実装を利用できるようにします。

src/server.ts を作成します。

cat > src/server.ts <<'TS'
import { AIChatAgent, type OnChatMessageOptions } from "@cloudflare/ai-chat";
import { callable, routeAgentRequest } from "agents";
import { convertToModelMessages, stepCountIs, streamText, tool } from "ai";
import { createWorkersAI } from "workers-ai-provider";
import {
  lookupCaseInput,
  parseInput,
  type LookupCaseInput,
  type SupportCase,
  type UpdatePriorityInput,
  updatePriorityInput
} from "./cases";
import { verifySessionRequest } from "./session-auth";

export class SupportToolsAgent extends AIChatAgent<Cloudflare.Env> {
  maxPersistedMessages = 12;

  private ensureCase(): void {
    this.sql`CREATE TABLE IF NOT EXISTS support_cases (
      ticket_id TEXT PRIMARY KEY,
      queue TEXT NOT NULL,
      case_summary TEXT NOT NULL,
      priority TEXT NOT NULL,
      revision INTEGER NOT NULL
    )`;
    this.sql`INSERT OR IGNORE INTO support_cases
      (ticket_id, queue, case_summary, priority, revision)
      VALUES ('T-SYNTH-101', ${this.name}, 'Synthetic customer cannot open a sample invoice', 'medium', 0)`;
  }

  private scopedCase(input: LookupCaseInput): SupportCase {
    if (input.queue !== this.name) throw new Error("queue is outside this Agent scope");
    this.ensureCase();
    const rows = this.sql<{
      queue: string;
      ticketId: "T-SYNTH-101";
      summary: string;
      priority: "low" | "medium" | "high";
      revision: number;
    }>`SELECT queue, ticket_id AS ticketId, case_summary AS summary, priority, revision
       FROM support_cases WHERE ticket_id = ${input.ticketId}`;
    const record = rows[0];
    if (!record || record.queue !== this.name) throw new Error("case not found in this Agent scope");
    return record;
  }

  @callable()
  inspectCase(input: unknown): SupportCase {
    return this.scopedCase(parseInput(lookupCaseInput, input));
  }

  @callable()
  setPriority(input: unknown): SupportCase {
    const parsed: UpdatePriorityInput = parseInput(updatePriorityInput, input);
    const current = this.scopedCase(parsed);
    if (parsed.expectedRevision !== current.revision) {
      throw new Error(`revision conflict: current revision is ${current.revision}`);
    }
    this.sql`UPDATE support_cases
      SET priority = ${parsed.priority}, revision = ${current.revision + 1}
      WHERE ticket_id = ${parsed.ticketId} AND queue = ${this.name}`;
    const changed = this.scopedCase(parsed);
    console.log(JSON.stringify({
      event: "tool_event",
      tool: "setSupportPriority",
      instance: this.name,
      revision: changed.revision
    }));
    return changed;
  }

  async onChatMessage(_onFinish: unknown, options?: OnChatMessageOptions) {
    const tools = {
      lookupSupportCase: tool({
        description: "Read synthetic ticket T-SYNTH-101 only from the current named support queue.",
        inputSchema: lookupCaseInput,
        execute: async (input) => this.inspectCase(input)
      }),
      setSupportPriority: tool({
        description: "Set low, medium or high priority on synthetic ticket T-SYNTH-101 in the current queue, using its observed revision.",
        inputSchema: updatePriorityInput,
        execute: async (input) => this.setPriority(input)
      })
    };
    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({
      model: workersai("@cf/zai-org/glm-4.7-flash", {
        reasoning_effort: null,
        chat_template_kwargs: { enable_thinking: false }
      }),
      system: `You assist only the synthetic ${this.name} queue. Use tools for case facts or changes. Never invent tool results, other queues or credentials. Keep the final answer to one short sentence.`,
      messages: await convertToModelMessages(this.messages),
      tools,
      stopWhen: stepCountIs(4),
      maxOutputTokens: 96,
      temperature: 0,
      abortSignal: options?.abortSignal
    });
    return result.toUIMessageStreamResponse();
  }
}

export default {
  async fetch(request: Request, env: Cloudflare.Env): Promise<Response> {
    const authorize = (candidate: Request, route: { name: string }) =>
      verifySessionRequest(candidate, route.name, env.SESSION_SIGNING_KEY);
    return (await routeAgentRequest(request, env, {
      onBeforeConnect: authorize,
      onBeforeRequest: authorize
    })) ?? new Response("Not found", { status: 404 });
  }
};
TS
python3 .labex/verify.py server

モデルがデータベースに直接アクセスすることはありません。モデルは型付き引数を提案し、execute が Durable Object 内のコードを呼び出します。そこでサーバーが現在の Agent 名を再度チェックします。2 つの @callable() メソッドは、まったく同じコードパスを再利用します。そのため、検証ツールはモデルの非決定的な選択に依存せず、不正なリクエスト、スコープ外のリクエスト、古いリクエストをテストできます。

データベースは、名前を付けた各 Agent 内で遅延作成されます。INSERT OR IGNORE により、既存の更新を上書きせずに、範囲を限定した初期データを 1 件用意できます。ログに記録するのは、ツール名、Agent インスタンス、リビジョンだけです。ケース本文は記録しません。

ツール対応チャットページを接続する

このステップでは、用意されているページのシェルを接続し、ツールのアクティビティをアシスタントのテキストとは別に表示します。

TypeScript と Vite の設定を作成します。

cat > tsconfig.json <<'JSON'
{
  "extends": "agents/tsconfig",
  "compilerOptions": {
    "jsx": "react-jsx",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": ["@cloudflare/workers-types", "vite/client", "node"]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "worker-configuration.d.ts"]
}
JSON

cat > vite.config.ts <<'TS'
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
import agents from "agents/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [react(), agents(), cloudflare()] });
TS

src/client.tsx を作成します。

cat > src/client.tsx <<'TSX'
import { useAgentChat } from "@cloudflare/ai-chat/react";
import { useAgent } from "agents/react";
import { Suspense } from "react";
import { createRoot } from "react-dom/client";

function ToolsChat() {
  const parameters = new URLSearchParams(window.location.search);
  const session = parameters.get("session") ?? "";
  const token = parameters.get("token") ?? "";
  if (!session || !token) {
    return <main><h1>Signed session required</h1><p className="help">Open the complete URL printed by the token command.</p></main>;
  }

  const agent = useAgent({
    agent: "SupportToolsAgent",
    name: session,
    host: window.location.host,
    query: { token }
  });
  const { messages, sendMessage, status, error } = useAgentChat({ agent });

  return (
    <main>
      <p className="eyebrow">Validated server-side tools</p>
      <h1>Synthetic Support Console</h1>
      <p className="scope">Allowed queue: <strong>{session}</strong> · allowed ticket: <strong>T-SYNTH-101</strong></p>
      <p className="status">Status: <strong>{status}</strong></p>
      <section className="messages" aria-live="polite">
        {messages.length === 0 && <p className="empty">No tool requests in this signed session yet.</p>}
        {messages.map((message) => (
          <article className={`message ${message.role}`} key={message.id}>
            <span className="role">{message.role}</span>
            {message.parts.map((part, index) => {
              if (part.type === "text") return <span key={index}>{part.text}</span>;
              if (part.type.startsWith("tool-")) {
                return <span className="tool" key={index}>{part.type.replace("tool-", "tool: ")}</span>;
              }
              return null;
            })}
          </article>
        ))}
      </section>
      <form => {
        event.preventDefault();
        const input = event.currentTarget.elements.namedItem("message") as HTMLInputElement;
        const text = input.value.trim();
        if (!text) return;
        sendMessage({ text });
        input.value = "";
      }}>
        <input name="message" defaultValue={`Look up T-SYNTH-101 in ${session}, then set its priority to high using the current revision. Briefly confirm the result.`} maxLength={220} aria-label="Tool request" />
        <button type="submit" disabled={status === "streaming" || status === "submitted"}>Send</button>
      </form>
      <p className="notice">Training fixture only: this page cannot reach a real support system.</p>
      {error && <p className="error" role="alert">{error.message}</p>}
    </main>
  );
}

createRoot(document.getElementById("root")!).render(
  <Suspense fallback={<main><p>Restoring the signed tool session…</p></main>}><ToolsChat /></Suspense>
);
TSX
python3 .labex/verify.py client

useAgent() は、短時間だけ有効なトークンを使って、名前を指定した 1 つの Agent に接続します。useAgentChat() は、永続化された会話とストリーミング中の応答を表示します。ツール部分には、アシスタントの文章に単純に埋め込むのではなく、アクティビティとしてラベルを付けます。これにより、学習者は「モデルが操作を要求した」ことと「モデルが文章を書いた」ことを区別できます。ブラウザーからサーバー側の検証を回避することはできません。

ローカルでビルドし、境界を検証する

このステップでは、アプリケーションをコンパイルし、モデル呼び出しを消費せずに実際のツール実装を実行します。

正確な環境型を生成し、両方のバンドルを型チェックしてビルドします。

npx wrangler types
npm run check
npm run build
python3 .labex/verify.py build

Wrangler は実際のバインディングから Cloudflare.Env を導出します。これにより、手書きした環境インターフェースが wrangler.jsonc とずれるのを防げます。

Workers AI はリモートバインディングなので、ローカルランタイムでは Wrangler が保存した OAuth アクセス情報が必要です。トークンは子プロセスにだけ渡し、シェル側のコピーはすぐに消去します。

DEV_PROXY_TOKEN="$(npx wrangler auth token --json | node -e 'let data="";process.stdin.on("data",chunk=>data+=chunk).on("end",()=>process.stdout.write(JSON.parse(data).token))')"
CLOUDFLARE_API_TOKEN="$DEV_PROXY_TOKEN" CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
unset DEV_PROXY_TOKEN
for attempt in $(seq 1 40); do
  curl --silent --fail http://127.0.0.1:5173/ > /dev/null && break
  sleep 1
done
tail -n 12 .labex/dev.log
python3 .labex/verify.py local

一時的な OAuth 値を表示したり、.dev.vars に保存したりしないでください。独立したプローブは、ランダムな名前の Agent を使い、モデルツールが使用するものと同じ inspectCase() および setPriority() メソッドを呼び出します。これにより、次の内容を検証できます。

  • 初期優先度が medium で、リビジョンが 0 であること。
  • 不正な読み取りと、別のキューを対象にした読み取りが失敗すること。
  • 有効な更新によって、リビジョン 1high になること。
  • リビジョン 0 を再利用した更新が失敗すること。
  • 別の名前の Agent が、分離されたリビジョン 0 のレコードを保持すること。

この決定論的なテストにより、操作が安全かどうかを確認できます。モデルの選択は確率的であるため、モデルによる動作はデプロイ後に別途確認します。

デプロイし、範囲を制限したツール実行を確認する

このステップでは、デプロイ後に Cloudflare に対して境界を再検証し、範囲を制限したライブモデルターンを 1 回確認します。

本番バンドルをデプロイし、生成した署名キーをシークレットとしてアップロードします。

npm run deploy
npx wrangler secret bulk .dev.vars

このシークレットコマンドは、値を設定やバンドルに含めずに送信します。.dev.vars を表示しないでください。

デプロイ時に表示された正確なオリジンを保存し、planning 用に 10 分間有効なトークンを作成します。

WORKER_URL="https://paste-the-workers-dev-origin-printed-by-deploy"
TOKEN="$(node scripts/create-session-token.mjs planning)"
printf '%s/?session=planning&token=%s\n' "${WORKER_URL%/}" "$TOKEN"

完全な URL を LabEx ブラウザーで開きます。用意されたリクエストを送信します。ステータスは submittedstreaming の順に変化します。ツールバッジには、モデルが読み取りと更新を要求したことが表示され、最後の文で優先度が新しいリビジョンの high になったことを確認できます。

検証済みの読み取りおよび更新ツールを実行した後の署名付き planning セッション

正確な文言はモデルによって生成されるため、異なる場合があります。キュー、チケット、レコードは合成データです。成功した文は UI 上の有用な証拠ですが、安全性を確認するための正式な判定材料ではありません。

2 回目のリクエストを送信します。Set T-SYNTH-101 to low using expected revision 0.

古いリビジョンによってリビジョン 1 が暗黙に上書きされてはいけません。代わりに、ツールのアクティビティに競合が表示されるはずです。

サーバー側のツール境界によって拒否された古いリビジョン

新しい名前を付けた Agent に対して、独立したクラウドプローブを実行します。別のモデル呼び出しは消費しません。

python3 .labex/verify.py deployed

このプローブは、デプロイ済みの正確なバインディングと名前空間を確認します。その後、リモート Worker に対して、スキーマによる拒否、スコープによる拒否、1 回の成功したリビジョン変更、古いリビジョンの再実行拒否、名前付き Agent の分離を繰り返し検証します。

ツールのリソースを確認して削除する

このステップでは、実行時の動作を Cloudflare のリソース画面と関連付け、その後、この実験のリソースだけを削除します。

Cloudflare Dashboard で Workers & Pages を開き、正確な labex-c11-s05-... Worker を選択して Bindings を確認します。AI Workers AI バインディングと SupportToolsAgent Durable Object バインディングが表示されるはずです。次に Settings > Variables and Secrets を開き、SESSION_SIGNING_KEY が平文ではなく暗号化されたシークレットとして保存されていることを確認します。

AI と SupportToolsAgent のバインディングを持つデプロイ済み Worker

Durable Objects を開き、この Worker が所有する SQL ベースの名前空間を選択します。planning と検証ツール用の名前は、1 つのクラス名前空間内にある別々のオブジェクトインスタンスです。

SQL ベースの SupportToolsAgent 名前空間

Worker のログまたはオブザーバビリティ画面を開き、tool_event を探します。構造化されたエントリにはツール名、Agent インスタンス、リビジョンが含まれますが、合成ケースの概要やチャット本文は含まれません。

Cloudflare ログに記録された範囲を制限した更新ツールイベント

確認が終わったら、クラス削除用のマイグレーションを明示的に作成し、この実験の Worker だけを削除します。

python3 - <<'PY'
import json
from pathlib import Path
path = Path('wrangler.jsonc')
data = json.loads(path.read_text())
data.pop('durable_objects', None)
data['migrations'].append({'tag': 'v2', 'deleted_classes': ['SupportToolsAgent']})
Path('wrangler.cleanup.jsonc').write_text(json.dumps(data, indent=2) + '\n')
PY
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force

破棄可能な Worker が存在しないことを確認します。

削除された破棄可能な検証済みツール Worker

次に、その SupportToolsAgent 名前空間も存在しないことを確認します。

削除された破棄可能な SupportToolsAgent 名前空間

この VM がまだ認証済みの間に、両方が存在しないことを検証します。

python3 .labex/verify.py deleted

Worker だけを削除すると、状態を持つクラスのライフサイクルが不明確なままになります。マイグレーション v2 により、この実験の名前空間と合成レコードが明示的に削除され、その後 Worker の削除が検証されます。

この VM の認証を取り消す

このステップでは、クラウドのクリーンアップが検証された後、一時的な VM の認証を取り消します。

npx wrangler logout
npx wrangler whoami --json || true

構造化された結果に "loggedIn": false と表示されるはずです。または、Wrangler が未認証を示す終了コード 0 以外の結果を返す場合があります。ログアウトを最後に実行するのは意図的なものです。削除検証には有効な読み取りアクセスが必要ですが、破棄する VM には必要ありません。

まとめ

Cloudflare の AIChatAgent に、範囲を制限したサーバー側ツールを 2 つ追加しました。次のことを実施しました。

  • 読み取りと合成データの更新に対して、厳格な Zod 契約を定義した。
  • 名前付き Agent のスコープをサーバー側で適用し、認可を分離した。
  • 不正な入力、別キューへのアクセス、古いリビジョンを拒否した。
  • モデルツールと決定論的な callable プローブで、まったく同じ実装を再利用した。
  • 範囲を制限した Workers AI ツールターンと、プライバシーを制限したログを確認した。
  • ログアウト前に、正確な SQLite クラス名前空間と Worker を削除した。

これらの制御により、合成データの直接更新を小さく、テスト可能にできます。ただし、変更を実行する前に人間の承認を求める仕組みはまだありません。次の実験では、その承認境界を追加し、承認、拒否、重複配信を明示的に扱います。