Añadir herramientas de soporte validadas

CloudflareBeginner
Practicar Ahora

Introducción

Un modelo de lenguaje puede sugerir qué hacer, pero una herramienta le permite solicitar una operación específica en el servidor. Este límite requiere más cuidado que un chat normal: los argumentos generados por el modelo son entradas no confiables, y una solicitud con el formato correcto aún puede dirigirse a la cola de soporte equivocada o sobrescribir trabajo más reciente.

En este laboratorio, proporcionará a un AIChatAgent dos herramientas deliberadamente pequeñas:

  1. lookupSupportCase lee un caso sintético del almacenamiento SQLite del propio Agent indicado.
  2. setSupportPriority modifica únicamente ese registro sintético.
  3. Los esquemas de Zod rechazan los argumentos con formato incorrecto antes de ejecutar cualquiera de las operaciones.
  4. Las comprobaciones del servidor aplican el nombre del Agent, la identidad del ticket y la revisión esperada.
  5. Un turno acotado de Workers AI puede llamar a las herramientas, mientras que una sonda independiente demuestra las mismas operaciones de forma determinista.

El registro editable es sintético y descartable; no hay ningún sistema real de mesa de ayuda conectado. Esto es importante porque la validación del esquema responde «¿la entrada tiene el formato correcto?», mientras que la autorización y las comprobaciones del ámbito responden «¿puede este Agent modificar ese registro?». El siguiente laboratorio añade un límite independiente de aprobación humana antes de ejecutar un efecto.

La página de React proporcionada y el token de sesión de corta duración le permiten centrarse en el diseño de herramientas, no en el código adicional del frontend o de la autenticación. Las asignaciones gratuitas de Workers AI se comparten con el resto de la actividad de la cuenta. Si la cuenta no tiene asignación disponible, deténgase en lugar de activar un plan de pago.

Antes de entrar directamente en este curso, complete Conectar LabEx con su cuenta de Cloudflare. Cada VM nueva de LabEx necesita su propia autorización de Wrangler. Se recomiendan los laboratorios anteriores del curso, pero sus VM y recursos nunca se reutilizan aquí.

Autorizar la VM y declarar el Worker de herramientas

En este paso, autorizará la VM nueva y declarará los recursos que utiliza el Agent con capacidad para usar herramientas.

Abra un terminal y entre en el proyecto preparado:

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

Autorice esta VM:

npx wrangler login

Abra el enlace mostrado, apruebe los permisos de Wrangler indicados para su cuenta de aprendizaje exclusiva y vuelva al terminal. Confirme el resultado estructurado:

npx wrangler whoami --json

Busque "loggedIn": true, confirme el nombre de la cuenta y copie el ID real de esa cuenta. Guárdelo junto con un nombre único de Worker descartable:

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

El enlace AI proporciona inferencia del modelo sin incluir una clave de API. El enlace del Durable Object proporciona a cada SupportToolsAgent identificado su propio almacenamiento SQLite. El navegador utilizará el nombre planning; un nombre distinto recibe una instancia independiente y no puede ver los datos de planning. Todavía no se ha desplegado nada.

Definir los contratos de las herramientas

En este paso, describirá exactamente qué argumentos acepta cada herramienta.

El esquema de una herramienta es un contrato en tiempo de ejecución. Los tipos de TypeScript ayudan durante la compilación, pero la salida del modelo llega en tiempo de ejecución y debe comprobarse de nuevo. Cree 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

El contrato de lectura acepta únicamente un nombre de cola válido y el único ticket sintético. El contrato de actualización añade una prioridad de tipo enumeración y una revisión que debe ser un entero no negativo. .strict() también rechaza campos inesperados, lo que reduce la ambigüedad y evita que un llamador introduzca instrucciones no admitidas en la operación.

expectedRevision es una comprobación de concurrencia optimista. El llamador indica qué versión observó; el servidor rechaza la actualización si alguien ya modificó esa versión. La validación por sí sola no concede acceso: el Agent comparará por separado queue con su propio nombre persistente.

Implementar herramientas con ámbito en el servidor

En este paso, conectará ambos esquemas con un registro local del Agent y expondrá la misma implementación al modelo y al verificador determinista.

Cree 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

El modelo nunca obtiene acceso directo a la base de datos. Propone argumentos tipados; execute invoca código en el Durable Object, donde el servidor vuelve a comprobar el nombre actual del Agent. Los dos métodos @callable() reutilizan esas mismas rutas de código para que el verificador pueda probar solicitudes con formato incorrecto, fuera del ámbito y obsoletas sin depender de decisiones no deterministas del modelo.

La base de datos se crea de forma diferida dentro de cada Agent identificado. INSERT OR IGNORE proporciona un recurso de prueba limitado sin sobrescribir una actualización anterior. Solo se registran metadatos —el nombre de la herramienta, la instancia del Agent y la revisión—; no se registra el texto del caso.

Conectar la página de chat compatible con herramientas

En este paso, conectará la estructura de página proporcionada y mostrará la actividad de las herramientas por separado del texto del asistente.

Cree la configuración de TypeScript y 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

Cree 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() se conecta exactamente a un Agent identificado mediante su token de corta duración. useAgentChat() muestra la conversación persistente y la respuesta transmitida. Las partes de las herramientas se etiquetan como actividad en lugar de integrarse en el texto del asistente, lo que ayuda al estudiante a distinguir entre «el modelo solicitó una operación» y «el modelo escribió texto». El navegador tampoco puede eludir la validación del servidor.

Compilar y demostrar los límites localmente

En este paso, compilará la aplicación y probará la implementación real de las herramientas sin consumir una llamada al modelo.

Genere los tipos exactos del entorno, compruebe los tipos y compile ambos paquetes:

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

Wrangler deriva Cloudflare.Env a partir de los enlaces reales. Esto evita que una interfaz de entorno escrita manualmente se desvíe de wrangler.jsonc.

Workers AI es un enlace remoto, por lo que el entorno local necesita el acceso OAuth que Wrangler ya almacenó. Páselo únicamente al proceso hijo y borre de inmediato la copia del 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

No muestre el valor temporal de OAuth ni lo guarde en .dev.vars. La sonda independiente utiliza un Agent identificado aleatoriamente y llama a los mismos métodos inspectCase() y setPriority() que usan las herramientas del modelo. Demuestra lo siguiente:

  • la prioridad inicial es medium en la revisión 0;
  • las lecturas con formato incorrecto y fuera de la cola fallan;
  • una actualización válida cambia la prioridad a high en la revisión 1;
  • repetir la revisión 0 falla; y
  • otro Agent identificado conserva su registro aislado en la revisión 0.

Esta prueba determinista responde si las operaciones son seguras. La selección del modelo se demuestra por separado después del despliegue porque es probabilística.

Desplegar y observar un turno de herramientas acotado

En este paso, desplegará la aplicación, volverá a demostrar los límites contra Cloudflare y observará un turno activo acotado del modelo.

Despliegue el paquete de producción y cargue la clave de firma generada como secreto:

npm run deploy
npx wrangler secret bulk .dev.vars

El comando de secretos envía el valor sin incluirlo en la configuración ni en el paquete. No muestre .dev.vars.

Guarde el origen exacto que muestra el despliegue y cree un token de diez minutos para 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"

Abra la URL completa en el navegador de LabEx. Envíe la solicitud preparada. El estado pasa por submitted y streaming; las insignias de las herramientas muestran que el modelo solicitó una lectura y una actualización, y la frase final confirma la prioridad high con la nueva revisión.

La sesión de planificación firmada después de ejecutar las herramientas de lectura y actualización validadas

La redacción exacta la genera el modelo y puede variar. La cola, el ticket y el registro son ejemplos sintéticos. Una frase correcta es una evidencia útil de la interfaz, pero no constituye la comprobación de seguridad autorizada.

Envíe una segunda solicitud: Set T-SYNTH-101 to low using expected revision 0. La revisión obsoleta no debe sobrescribir silenciosamente la revisión 1; en su lugar, la actividad de la herramienta debería mostrar un conflicto.

Una revisión obsoleta rechazada por el límite de la herramienta en el servidor

Ejecute una sonda en la nube nueva e independiente. No consume otra llamada al modelo:

python3 .labex/verify.py deployed

La sonda comprueba los enlaces y el espacio de nombres exactos del despliegue; después repite el rechazo del esquema, el rechazo del ámbito, un cambio de revisión correcto, el rechazo de la repetición obsoleta y el aislamiento entre Agents identificados contra el Worker remoto.

Inspeccionar y eliminar los recursos de las herramientas

En este paso, relacionará el comportamiento en tiempo de ejecución con las vistas de recursos de Cloudflare y después eliminará únicamente los recursos de este laboratorio.

En el Cloudflare Dashboard, abra Workers & Pages, seleccione su Worker exacto labex-c11-s05-... y revise Bindings. Debería ver el enlace de Workers AI AI y el enlace del Durable Object SupportToolsAgent. Después, abra Settings > Variables and Secrets para confirmar que SESSION_SIGNING_KEY está almacenado como secreto cifrado y no como texto sin formato:

El Worker desplegado con los enlaces AI y SupportToolsAgent

Abra Durable Objects y seleccione el espacio de nombres respaldado por SQL que pertenece a este Worker. planning y los nombres del verificador son instancias de objetos independientes dentro de este espacio de nombres de clase:

El espacio de nombres SupportToolsAgent respaldado por SQL

Abra los registros del Worker o la vista de observabilidad y busque tool_event. La entrada estructurada contiene el nombre de la herramienta, la instancia del Agent y la revisión, pero no el resumen del caso sintético ni el texto del chat:

Un evento acotado de la herramienta de actualización en los registros de Cloudflare

Después de inspeccionarlo, cree una migración explícita para eliminar la clase y quite el Worker exacto:

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

Confirme que el Worker descartable ya no está:

El Worker de herramientas validadas descartable eliminado

Después, confirme que su espacio de nombres SupportToolsAgent tampoco está:

El espacio de nombres SupportToolsAgent descartable eliminado

Demuestre ambas ausencias mientras esta VM aún está autorizada:

python3 .labex/verify.py deleted

Eliminar únicamente el Worker dejaría ambiguo el ciclo de vida de la clase con estado. La migración v2 elimina explícitamente el espacio de nombres de este laboratorio y sus registros sintéticos antes de verificar la eliminación del Worker.

Revocar la autorización de esta VM

En este paso, revocará la autorización temporal de la VM después de demostrar que la limpieza en la nube se completó.

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

El resultado estructurado debería indicar "loggedIn": false, aunque Wrangler también puede devolver un resultado no autenticado con código distinto de cero. El cierre de sesión se realiza deliberadamente al final: el verificador de eliminación necesita acceso de lectura válido, mientras que la VM descartada ya no lo necesita.

Resumen

Añadió dos herramientas acotadas en el servidor a un AIChatAgent de Cloudflare. Usted:

  • definió contratos estrictos de Zod para una lectura y una actualización sintética;
  • mantuvo separada la autorización aplicando en el servidor el ámbito del Agent identificado;
  • rechazó entradas con formato incorrecto, accesos fuera de la cola y revisiones obsoletas;
  • reutilizó la implementación exacta para las herramientas del modelo y las sondas deterministas invocables;
  • observó un turno acotado de herramientas de Workers AI y registros con privacidad limitada; y
  • eliminó el espacio de nombres exacto de la clase SQLite y el Worker antes de cerrar la sesión.

Estos controles hacen que una actualización sintética directa sea pequeña y comprobable, pero no solicitan a una persona que apruebe el efecto. El siguiente laboratorio añade ese límite de aprobación y hace explícitas la aprobación, la denegación y la entrega duplicada.