Ajouter des outils de support validés

CloudflareBeginner
Pratiquer maintenant

Introduction

Un modèle de langage peut suggérer quoi faire, mais un outil lui permet de demander une opération précise côté serveur. Cette frontière exige davantage de précautions qu’une conversation ordinaire : les arguments générés par le modèle sont des entrées non fiables, et une requête correctement formée peut malgré tout cibler la mauvaise file de support ou écraser un travail plus récent.

Dans ce lab, vous allez donner à un AIChatAgent deux outils volontairement simples :

  1. lookupSupportCase lit un dossier synthétique dans le stockage SQLite de l’Agent nommé.
  2. setSupportPriority ne modifie que cet enregistrement synthétique.
  3. Les schémas Zod rejettent les arguments mal formés avant l’exécution de l’une ou l’autre opération.
  4. Des vérifications côté serveur imposent le nom de l’Agent, l’identité du ticket et la révision attendue.
  5. Une requête Workers AI limitée peut appeler les outils, tandis qu’une sonde indépendante vérifie les mêmes opérations de manière déterministe.

L’enregistrement modifiable est synthétique et temporaire ; aucun système réel de support n’est connecté. C’est important, car la validation du schéma répond à la question « l’entrée a-t-elle la bonne structure ? », tandis que l’autorisation et les vérifications de périmètre répondent à la question « cet Agent peut-il modifier cet enregistrement ? ». Le lab suivant ajoutera une validation humaine distincte avant l’application d’un effet.

La page React fournie et le jeton de session à durée limitée vous permettent de vous concentrer sur la conception des outils plutôt que sur le code standard du frontend ou de l’authentification. Les allocations gratuites de Workers AI sont partagées avec les autres activités du compte. Si le compte n’a plus d’allocation disponible, arrêtez-vous au lieu d’activer une offre payante.

Avant d’accéder directement à ce cours, terminez Connect LabEx to Your Cloudflare Account. Chaque nouvelle VM LabEx doit disposer de sa propre autorisation Wrangler. Les labs précédents du cours sont recommandés, mais leurs VM et leurs ressources ne sont jamais réutilisées ici.

Autoriser la VM et déclarer le Worker d’outils

Dans cette étape, vous allez autoriser la nouvelle VM et déclarer les ressources utilisées par l’Agent capable d’appeler des outils.

Ouvrez un terminal et accédez au projet préparé :

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

Autorisez cette VM :

npx wrangler login

Ouvrez le lien affiché, approuvez les autorisations Wrangler indiquées pour votre compte d’apprentissage dédié, puis revenez au terminal. Vérifiez le résultat structuré :

npx wrangler whoami --json

Recherchez "loggedIn": true, vérifiez le nom du compte et copiez l’identifiant réel de ce compte. Enregistrez-le avec un nom de Worker temporaire unique :

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

La liaison AI fournit l’inférence du modèle sans intégrer de clé API. La liaison Durable Object donne à chaque SupportToolsAgent nommé son propre stockage SQLite. Le navigateur utilisera le nom planning ; un autre nom reçoit une autre instance et ne peut pas voir les données de planning. Rien n’a encore été déployé.

Définir les contrats des outils

Dans cette étape, vous allez décrire précisément les arguments acceptés par chaque outil.

Un schéma d’outil est un contrat vérifié à l’exécution. Les types TypeScript sont utiles lors de la compilation, mais la sortie du modèle arrive à l’exécution et doit être contrôlée une nouvelle fois. Créez 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

Le contrat de lecture accepte uniquement un nom de file valide et l’unique ticket synthétique. Le contrat de mise à jour ajoute une priorité issue d’une énumération et une révision entière positive ou nulle. .strict() rejette également les champs inattendus, ce qui réduit l’ambiguïté et empêche un appelant d’injecter des instructions non prises en charge dans l’opération.

expectedRevision est une vérification de concurrence optimiste. L’appelant indique la version qu’il a observée ; le serveur refuse la mise à jour si cette version a déjà été modifiée. La validation n’accorde pas à elle seule l’accès : l’Agent comparera séparément queue avec son propre nom durable.

Implémenter des outils côté serveur avec périmètre

Dans cette étape, vous allez relier les deux schémas à un enregistrement local de l’Agent et exposer la même implémentation au modèle et au vérificateur déterministe.

Créez 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

Le modèle n’a jamais accès directement à la base de données. Il propose des arguments typés ; execute appelle du code dans le Durable Object, où le serveur vérifie à nouveau le nom actuel de l’Agent. Les deux méthodes @callable() réutilisent exactement ces chemins de code afin que le vérificateur puisse tester les requêtes mal formées, les requêtes hors périmètre et les requêtes obsolètes sans dépendre de choix non déterministes du modèle.

La base de données est créée à la demande dans chaque Agent nommé. INSERT OR IGNORE fournit un jeu de données limité sans écraser une mise à jour précédente. Seules les métadonnées — nom de l’outil, instance de l’Agent et révision — sont consignées ; le texte du dossier ne l’est pas.

Connecter la page de conversation compatible avec les outils

Dans cette étape, vous allez connecter la structure de page fournie et afficher l’activité des outils séparément du texte de l’assistant.

Créez la configuration TypeScript et 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

Créez 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 connecte à un seul Agent nommé avec son jeton à durée limitée. useAgentChat() affiche la conversation persistante et la réponse diffusée progressivement. Les parties d’outil sont signalées comme une activité plutôt que fondues dans le texte de l’assistant, ce qui aide l’apprenant à distinguer « le modèle a demandé une opération » de « le modèle a écrit du texte ». Le navigateur ne peut toujours pas contourner la validation côté serveur.

Compiler et vérifier les périmètres localement

Dans cette étape, vous allez compiler l’application et tester l’implémentation réelle des outils sans consommer d’appel au modèle.

Générez les types exacts de l’environnement, vérifiez les types et compilez les deux bundles :

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

Wrangler déduit Cloudflare.Env à partir des liaisons réelles. Cela évite qu’une interface d’environnement écrite manuellement diverge de wrangler.jsonc.

Workers AI utilise une liaison distante ; l’environnement local a donc besoin de l’accès OAuth déjà enregistré par Wrangler. Transmettez-le uniquement au processus enfant, puis effacez immédiatement la copie conservée par le 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

N’affichez pas la valeur OAuth temporaire et ne l’enregistrez pas dans .dev.vars. La sonde indépendante utilise un Agent nommé aléatoirement et appelle les mêmes méthodes inspectCase() et setPriority() que celles utilisées par les outils du modèle. Elle vérifie que :

  • la priorité initiale est medium à la révision 0 ;
  • les lectures mal formées et les lectures concernant une autre file échouent ;
  • une mise à jour valide devient high à la révision 1 ;
  • la répétition avec la révision 0 échoue ; et
  • un autre Agent nommé conserve son propre enregistrement isolé à la révision 0.

Ce test déterministe vérifie si les opérations sont sûres. Le choix du modèle sera démontré séparément après le déploiement, car il est probabiliste.

Déployer et observer une exécution d’outil limitée

Dans cette étape, vous allez déployer l’application, vérifier de nouveau les périmètres sur Cloudflare et observer une exécution réelle limitée du modèle.

Déployez le bundle de production et téléversez la clé de signature générée comme secret :

npm run deploy
npx wrangler secret bulk .dev.vars

La commande de secret envoie la valeur sans la placer dans la configuration ni dans le bundle. N’affichez pas .dev.vars.

Enregistrez exactement l’origine affichée par le déploiement, puis créez un jeton valable dix minutes pour 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"

Ouvrez l’URL complète dans le navigateur LabEx. Envoyez la requête préparée. Le statut passe par submitted puis streaming ; les badges d’outil indiquent que le modèle a demandé une lecture et une mise à jour, et la phrase finale confirme la priorité high avec la nouvelle révision.

La session planning signée après l’exécution des outils de lecture et de mise à jour validés

La formulation exacte est générée par le modèle et peut varier. La file, le ticket et l’enregistrement sont des exemples synthétiques. Une phrase de réussite constitue un indice utile dans l’interface, mais ne représente pas la vérification de sécurité faisant autorité.

Envoyez une deuxième requête : Set T-SYNTH-101 to low using expected revision 0. La révision obsolète ne doit pas écraser silencieusement la révision 1 ; l’activité de l’outil doit plutôt signaler un conflit.

Une révision obsolète rejetée par la frontière de l’outil côté serveur

Exécutez une nouvelle sonde cloud avec un nom indépendant. Elle ne consomme pas d’appel supplémentaire au modèle :

python3 .labex/verify.py deployed

La sonde vérifie les liaisons et l’espace de noms réellement déployés, puis répète le rejet du schéma, le rejet du périmètre, une modification de révision réussie, le rejet de la répétition obsolète et l’isolation entre Agents nommés sur le Worker distant.

Inspecter et supprimer les ressources des outils

Dans cette étape, vous allez relier le comportement à l’exécution aux vues des ressources Cloudflare, puis supprimer uniquement les ressources de ce lab.

Dans le Cloudflare Dashboard, ouvrez Workers & Pages, sélectionnez le Worker labex-c11-s05-... exact et inspectez Bindings. Vous devez voir la liaison Workers AI AI et la liaison Durable Object SupportToolsAgent. Ouvrez ensuite Settings > Variables and Secrets pour confirmer que SESSION_SIGNING_KEY est stockée comme secret chiffré et non en texte brut :

Le Worker déployé avec les liaisons AI et SupportToolsAgent

Ouvrez Durable Objects et sélectionnez l’espace de noms soutenu par SQL et détenu par ce Worker. planning et les noms utilisés par le vérificateur sont des instances d’objet distinctes au sein de cet espace de noms de classe :

L’espace de noms SupportToolsAgent soutenu par SQL

Ouvrez les journaux du Worker ou la vue d’observabilité et recherchez tool_event. L’entrée structurée contient le nom de l’outil, l’instance de l’Agent et la révision, mais pas le résumé du dossier synthétique ni le texte de la conversation :

Un événement limité de l’outil de mise à jour dans les journaux Cloudflare

Après l’inspection, créez une migration explicite de suppression de classe et supprimez le Worker exact :

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

Confirmez que le Worker temporaire a disparu :

Le Worker disposable de validation des outils a été supprimé

Confirmez ensuite que son espace de noms SupportToolsAgent a disparu :

L’espace de noms SupportToolsAgent temporaire a été supprimé

Vérifiez ces deux absences tant que cette VM est encore autorisée :

python3 .labex/verify.py deleted

Supprimer uniquement le Worker laisserait le cycle de vie de la classe avec état ambigu. La migration v2 supprime explicitement l’espace de noms de ce lab et ses enregistrements synthétiques avant la vérification de la suppression du Worker.

Révoquer l’autorisation de cette VM

Dans cette étape, vous allez révoquer l’autorisation temporaire de la VM après avoir vérifié le nettoyage cloud.

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

Le résultat structuré doit indiquer "loggedIn": false, ou Wrangler peut renvoyer un résultat non nul indiquant que l’utilisateur n’est pas authentifié. La déconnexion est volontairement la dernière étape : le vérificateur de suppression a besoin d’un accès en lecture valide, tandis que la VM abandonnée n’en a plus besoin.

Résumé

Vous avez ajouté deux outils côté serveur, avec un périmètre limité, à un AIChatAgent Cloudflare. Vous avez :

  • défini des contrats Zod stricts pour une lecture et une mise à jour synthétique ;
  • séparé l’autorisation en imposant côté serveur le périmètre de l’Agent nommé ;
  • rejeté les entrées mal formées, les accès à une autre file et les révisions obsolètes ;
  • réutilisé exactement la même implémentation pour les outils du modèle et les sondes déterministes appelables ;
  • observé une exécution limitée d’outil Workers AI ainsi que des journaux ne contenant qu’un minimum de données ; et
  • supprimé l’espace de noms exact de la classe SQLite et le Worker avant la déconnexion.

Ces contrôles rendent une mise à jour synthétique directe limitée et testable, mais ils ne demandent pas à une personne d’approuver l’effet. Le prochain lab ajoute cette frontière d’approbation et rend explicites l’approbation, le refus et la livraison en double.