添加经过验证的支持工具

CloudflareBeginner
立即练习

简介

语言模型可以建议下一步该做什么,但工具可以让它请求执行一项具体的服务器端操作。与普通聊天相比,这个边界需要更加谨慎:模型生成的参数是不受信任的输入,即使请求格式正确,也可能操作错误的支持队列,或覆盖更新版本更高的数据。

在本实验中,你将为一个 AIChatAgent 添加两个刻意保持简单的工具:

  1. lookupSupportCase 从指定 Agent 自己的 SQLite 存储中读取一条合成工单记录。
  2. setSupportPriority 只修改这条合成记录。
  3. Zod schema 会在任一操作执行前拒绝格式错误的参数。
  4. 服务器端检查会校验 Agent 名称、工单身份和预期版本。
  5. 一次受限的 Workers AI 调用可以使用这些工具,同时,独立探针会确定性地验证相同的操作。

可写入的记录是合成数据,随时可以删除;本实验不会连接真实的帮助台系统。这一点很重要,因为 schema 验证回答的是「输入格式是否正确」,而授权和作用域检查回答的是「这个 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 binding 提供模型推理能力,无需在代码中嵌入 API key。Durable Object binding 会为每个命名的 SupportToolsAgent 提供独立的 SQLite 存储。浏览器将使用 planning 这个名称;使用其他名称会得到独立实例,无法查看 planning 的数据。此时还没有部署任何内容。

定义工具契约

在本步骤中,你将准确描述每个工具接受哪些参数。

工具 schema 是运行时契约。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

读取契约只接受有效的队列名称和这一张合成工单。更新契约额外要求一个枚举类型的优先级,以及一个非负整数版本号。.strict() 还会拒绝未预期的字段,从而减少歧义,并阻止调用方将不受支持的指令偷偷带入操作。

expectedRevision 是一种乐观并发检查。调用方会声明它所观察到的版本;如果其他人已经修改了该版本,服务器就会拒绝更新。验证本身不会授予访问权限——Agent 还会单独将 queue 与自己的持久化名称进行比较。

实现带作用域限制的服务器端工具

在本步骤中,你将把两个 schema 连接到 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 名称。这两个 @callable() 方法复用完全相同的代码路径,因此验证器可以在不依赖模型非确定性选择的情况下,测试格式错误、跨作用域和过期请求。

每个命名 Agent 都会在首次使用时创建数据库。INSERT OR IGNORE 会提供一条受限的测试记录,但不会覆盖之前的更新。日志只记录元数据——工具名称、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() 会使用短时令牌连接到一个确定的命名 Agent。useAgentChat() 会呈现持久化对话和流式响应。工具部分会作为活动单独标记,而不是直接混入助手文本中,这有助于学习者区分「模型请求执行某项操作」和「模型生成了文字」。浏览器仍然无法绕过服务器端验证。

在本地构建并验证边界

在本步骤中,你将编译应用,并在不消耗模型调用的情况下执行实际的工具实现。

生成准确的环境类型,进行类型检查,并构建两个 bundle:

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

Wrangler 会根据实际 binding 推导 Cloudflare.Env。这样可以避免手写的环境接口与 wrangler.jsonc 中的配置逐渐不一致。

Workers AI 是远程 binding,因此本地运行时需要使用 Wrangler 已保存的 OAuth 访问权限。只将它传递给子进程,然后立即清除 shell 中的副本:

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
  • 格式错误和跨队列读取都会失败;
  • 一次有效更新会将优先级改为 high,版本改为 1
  • 使用版本 0 重放请求会失败;以及
  • 另一个命名 Agent 仍保留自己隔离的版本 0 记录。

这个确定性测试可以回答这些操作是否安全。模型选择具有概率性,因此会在部署后单独演示。

部署并观察一次受限工具调用

在本步骤中,你将部署应用,再次针对 Cloudflare 验证边界,并观察一次受限的线上模型调用。

部署生产 bundle,并将生成的签名密钥作为 secret 上传:

npm run deploy
npx wrangler secret bulk .dev.vars

secret 命令会发送该值,但不会将它放入配置或 bundle 中。不要打印 .dev.vars

保存部署输出中的准确 origin,然后为 planning 创建一个有效期十分钟的令牌:

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"

在 LabEx 浏览器中打开完整 URL。发送预先填写的请求。状态会依次变为 submittedstreaming;工具徽章会显示模型请求执行了读取和更新操作,最终句子会确认优先级为 high,并显示新的版本。

经过验证的读取和更新工具执行后的签名 planning 会话

具体措辞由模型生成,可能有所不同。队列、工单和记录都是合成示例。成功的句子可以作为 UI 证据,但不是权威的安全检查结果。

发送第二个请求:Set T-SYNTH-101 to low using expected revision 0. 过期版本不能悄悄覆盖版本 1;工具活动应显示冲突。

服务器端工具边界拒绝过期版本更新

运行一个新建且名称独立的云端探针。它不会消耗额外的模型调用:

python3 .labex/verify.py deployed

该探针会检查实际部署的 binding 和命名空间,然后针对远程 Worker 重复验证 schema 拒绝、作用域拒绝、一次成功的版本变更、过期版本重放拒绝,以及命名 Agent 隔离。

检查并删除工具资源

在本步骤中,你将把运行时行为与 Cloudflare 的资源视图对应起来,然后只删除本实验创建的资源。

在 Cloudflare Dashboard 中打开 Workers & Pages,选择准确的 labex-c11-s05-... Worker,然后查看 Bindings。你应该能看到 AI Workers AI binding 和 SupportToolsAgent Durable Object binding。接着打开 Settings > Variables and Secrets,确认 SESSION_SIGNING_KEY 以加密 secret 的形式保存,而不是明文:

包含 AI 和 SupportToolsAgent binding 的已部署 Worker

打开 Durable Objects,选择由此 Worker 所有的 SQL-backed 命名空间。planning 和验证器名称是同一个类命名空间中的不同对象实例:

SQL-backed 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 可能返回表示未认证的非零退出状态。注销操作特意放在最后:删除验证器需要有效的读取权限,而已经丢弃的 VM 不再需要这些权限。

总结

你为 Cloudflare AIChatAgent 添加了两个受限的服务器端工具。你完成了以下工作:

  • 为读取和合成记录更新定义严格的 Zod 契约;
  • 通过在服务器端强制检查命名 Agent 的作用域,将授权逻辑独立出来;
  • 拒绝格式错误的输入、跨队列访问和过期版本;
  • 让模型工具和确定性的 callable 探针复用完全相同的实现;
  • 观察一次受限的 Workers AI 工具调用,以及隐私受限的日志;并且
  • 在注销前删除准确的 SQLite 类命名空间和 Worker。

这些控制措施使直接更新合成记录的操作保持简单且可测试,但它们不会要求人员批准该副作用。下一个实验会添加这一审批边界,并明确处理批准、拒绝和重复传递。