Введение
Языковая модель может предложить, что делать, но инструмент позволяет ей запросить конкретную операцию на сервере. Такая граница требует большего внимания, чем обычный чат: аргументы, сгенерированные моделью, являются непроверенными входными данными, а даже правильно сформированный запрос может обратиться не к той очереди поддержки или перезаписать более новую версию данных.
В этой лабораторной работе вы добавите в один AIChatAgent два намеренно небольших инструмента:
lookupSupportCaseчитает один синтетический тикет из собственного SQLite-хранилища именованного Agent.setSupportPriorityизменяет только эту синтетическую запись.- Схемы Zod отклоняют некорректные аргументы до запуска любой из операций.
- Серверные проверки контролируют имя Agent, идентификатор тикета и ожидаемую версию.
- Ограниченный запрос 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 и новую версию.

Точная формулировка создается моделью и может отличаться. Очередь, тикет и запись являются синтетическими примерами. Успешное предложение полезно как свидетельство работы интерфейса, но не является главным подтверждением безопасности.
Отправьте второй запрос: 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 хранится как зашифрованный секрет, а не как обычный текст:

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

Откройте журналы Worker или представление наблюдаемости и найдите tool_event. Структурированная запись содержит имя инструмента, экземпляр Agent и версию, но не содержит сводку синтетического тикета или текст чата:

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

Затем убедитесь, что его пространство имен 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 до выхода из аккаунта.
Эти меры делают прямое синтетическое обновление небольшим и тестируемым, но не требуют от человека одобрения изменения. В следующей лабораторной работе будет добавлена граница такого одобрения, а также явная обработка одобрения, отклонения и повторной доставки.



