Добавление проверенных инструментов поддержки

CloudflareBeginner
Практиковаться сейчас

Введение

Языковая модель может предложить, что делать, но инструмент позволяет ей запросить конкретную операцию на сервере. Такая граница требует большего внимания, чем обычный чат: аргументы, сгенерированные моделью, являются непроверенными входными данными, а даже правильно сформированный запрос может обратиться не к той очереди поддержки или перезаписать более новую версию данных.

В этой лабораторной работе вы добавите в один AIChatAgent два намеренно небольших инструмента:

  1. lookupSupportCase читает один синтетический тикет из собственного SQLite-хранилища именованного Agent.
  2. setSupportPriority изменяет только эту синтетическую запись.
  3. Схемы Zod отклоняют некорректные аргументы до запуска любой из операций.
  4. Серверные проверки контролируют имя Agent, идентификатор тикета и ожидаемую версию.
  5. Ограниченный запрос Workers AI может вызвать инструменты, а независимая проверка детерминированно подтверждает работу тех же операций.

Изменяемая запись синтетическая и предназначена только для лабораторной работы; реальная система поддержки не подключается. Это важно, потому что проверка схемы отвечает на вопрос «правильно ли сформированы входные данные?», а проверки авторизации и области действия — на вопрос «может ли этот Agent изменить эту запись?». В следующей лабораторной работе перед изменением будет добавлена отдельная граница одобрения человеком.

Готовая страница React и краткоживущий токен сессии позволяют сосредоточиться на проектировании инструментов, а не на шаблонном коде frontend и аутентификации. Бесплатные лимиты Workers AI общие для другой активности аккаунта. Если в аккаунте не осталось доступного лимита, остановитесь и не подключайте платный план.

Прежде чем напрямую приступить к этому курсу, выполните лабораторную работу Подключение LabEx к аккаунту Cloudflare. Каждой новой виртуальной машине LabEx требуется собственная авторизация Wrangler. Предыдущие лабораторные работы курса рекомендуются, но их виртуальные машины и ресурсы здесь никогда не используются повторно.

Авторизация виртуальной машины и объявление Worker для инструментов

На этом шаге вы авторизуете новую виртуальную машину и объявите ресурсы, которые использует Agent с поддержкой инструментов.

Откройте терминал и перейдите в подготовленный проект:

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

Авторизуйте эту виртуальную машину:

npx wrangler login

Откройте показанную ссылку, подтвердите указанные разрешения Wrangler для выделенного учебного аккаунта и вернитесь в терминал. Проверьте структурированный результат:

npx wrangler whoami --json

Найдите "loggedIn": true, проверьте имя аккаунта и скопируйте фактический идентификатор этого аккаунта. Сохраните его вместе с уникальным временным именем 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

Контракт чтения принимает только допустимое имя очереди и один синтетический тикет. Контракт обновления дополнительно требует приоритет из перечисления и неотрицательную целочисленную версию. .strict() также отклоняет неожиданные поля, уменьшая неоднозначность и не позволяя вызывающей стороне передавать в операцию неподдерживаемые инструкции.

expectedRevision — это проверка оптимистической конкурентности. Вызывающая сторона сообщает, какую версию она видела; сервер отклоняет обновление, если кто-то уже изменил эту версию. Сама проверка данных не предоставляет доступ — Agent отдельно сравнит queue со своим собственным постоянным именем.

Реализация инструментов с серверной проверкой области действия

На этом шаге вы подключите обе схемы к локальной записи 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() отображает постоянную переписку и потоковый ответ. Части, относящиеся к инструментам, показываются как активность, а не объединяются с текстом помощника. Это помогает отличать «модель запросила операцию» от «модель написала текст». Браузер по-прежнему не может обойти серверную проверку.

Сборка и локальная проверка границ

На этом шаге вы скомпилируете приложение и протестируете фактическую реализацию инструментов, не расходуя вызов модели.

Сгенерируйте точные типы окружения, проверьте типы и соберите оба пакета:

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

Wrangler выводит Cloudflare.Env из фактических привязок. Это предотвращает расхождение вручную написанного интерфейса окружения с wrangler.jsonc.

Workers AI использует удаленную привязку, поэтому локальной среде выполнения нужна OAuth-авторизация, уже сохраненная Wrangler. Передайте ее только дочернему процессу и сразу удалите копию из оболочки:

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 после развертывания и понаблюдаете за одним ограниченным вызовом модели в рабочей среде.

Разверните рабочий пакет и загрузите созданный ключ подписи как секрет:

npm run deploy
npx wrangler secret bulk .dev.vars

Команда работы с секретом передает значение, не помещая его в конфигурацию или пакет. Не выводите .dev.vars.

Сохраните точный origin, показанный после развертывания, и создайте десятiminутный токен для 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"

Откройте полный URL в браузере LabEx. Отправьте подготовленный запрос. Статус пройдет через submitted и streaming; значки инструментов покажут, что модель запросила чтение и обновление, а итоговое предложение подтвердит приоритет high и новую версию.

Подписанная сессия planning после выполнения проверенных инструментов чтения и обновления

Точная формулировка создается моделью и может отличаться. Очередь, тикет и запись являются синтетическими примерами. Успешное предложение полезно как свидетельство работы интерфейса, но не является главным подтверждением безопасности.

Отправьте второй запрос: Set T-SYNTH-101 to low using expected revision 0. Устаревшая версия не должна незаметно перезаписать версию 1; вместо этого активность инструмента должна показать конфликт.

Устаревшая версия отклонена серверной границей инструмента

Запустите новую независимую облачную проверку. Она не расходует дополнительный вызов модели:

python3 .labex/verify.py deployed

Проверка контролирует фактические развернутые привязки и пространство имен, затем повторяет проверку отклонения по схеме, отклонения запроса за пределами области действия, успешного изменения версии, отклонения повторного запроса с устаревшей версией и изоляции именованных Agent удаленного Worker.

Просмотр и удаление ресурсов инструментов

На этом шаге вы сопоставите поведение среды выполнения с представлениями ресурсов Cloudflare, а затем удалите только ресурсы этой лабораторной работы.

В Cloudflare Dashboard откройте Workers & Pages, выберите точный Worker labex-c11-s05-... и откройте раздел Bindings. Вы должны увидеть привязку Workers AI AI и привязку Durable Object SupportToolsAgent. Затем откройте Settings > Variables and Secrets и убедитесь, что SESSION_SIGNING_KEY хранится как зашифрованный секрет, а не как обычный текст:

Развернутый Worker с привязками AI и SupportToolsAgent

Откройте Durable Objects и выберите пространство имен на основе SQL, принадлежащее этому Worker. planning и имена проверяющих являются отдельными экземплярами объектов внутри одного пространства имен класса:

Пространство имен SupportToolsAgent на основе SQL

Откройте журналы 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 удалено

Подтвердите оба факта, пока эта виртуальная машина еще авторизована:

python3 .labex/verify.py deleted

Если удалить только Worker, жизненный цикл состояния класса останется неоднозначным. Миграция v2 явно удаляет пространство имен этой лабораторной работы и его синтетические записи до проверки удаления Worker.

Отзыв авторизации этой виртуальной машины

На этом шаге вы отзовете временную авторизацию виртуальной машины после подтверждения очистки в облаке.

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

Структурированный результат должен содержать "loggedIn": false; также Wrangler может вернуть ненулевой код завершения, сообщая об отсутствии авторизации. Выход из аккаунта выполняется намеренно последним: проверяющему коду удаления нужен действующий доступ на чтение, а отброшенной виртуальной машине он больше не нужен.

Итоги

Вы добавили два ограниченных серверных инструмента в Cloudflare AIChatAgent. Вы:

  • определили строгие контракты Zod для чтения и синтетического обновления;
  • отделили авторизацию, проверяя область действия именованного Agent на сервере;
  • отклоняли некорректные входные данные, доступ к другой очереди и устаревшие версии;
  • использовали одну и ту же реализацию для инструментов модели и детерминированных проверок через callable;
  • наблюдали один ограниченный вызов инструмента Workers AI и журналы с ограниченным набором данных; и
  • удалили точное пространство имен класса SQLite и Worker до выхода из аккаунта.

Эти меры делают прямое синтетическое обновление небольшим и тестируемым, но не требуют от человека одобрения изменения. В следующей лабораторной работе будет добавлена граница такого одобрения, а также явная обработка одобрения, отклонения и повторной доставки.