Adicionar ferramentas de suporte validadas

CloudflareBeginner
Pratique Agora

Introdução

Um modelo de linguagem pode sugerir o que fazer, mas uma ferramenta permite que ele solicite uma operação específica no servidor. Esse limite exige mais cuidado do que um chat comum: os argumentos gerados pelo modelo são entradas não confiáveis, e uma solicitação com formato correto ainda pode atingir a fila de suporte errada ou sobrescrever um trabalho mais recente.

Neste laboratório, você fornecerá a um AIChatAgent duas ferramentas deliberadamente pequenas:

  1. lookupSupportCase lê um caso sintético do armazenamento SQLite do próprio Agent identificado pelo nome.
  2. setSupportPriority altera somente esse registro sintético.
  3. Os esquemas Zod rejeitam argumentos malformados antes da execução de qualquer operação.
  4. As verificações no servidor impõem o nome do Agent, a identidade do ticket e a revisão esperada.
  5. Uma execução limitada do Workers AI pode chamar as ferramentas, enquanto uma verificação independente comprova deterministicamente as mesmas operações.

O registro editável é sintético e descartável; nenhum sistema real de help desk está conectado. Isso é importante porque a validação de esquema responde “a entrada está no formato correto?”, enquanto a autorização e as verificações de escopo respondem “este Agent pode alterar esse registro?”. O próximo laboratório adicionará um limite separado de aprovação humana antes de um efeito.

A página React fornecida e o token de sessão de curta duração mantêm o foco no design das ferramentas, e não em código padrão de frontend ou autenticação. As alocações gratuitas do Workers AI são compartilhadas com outras atividades da conta. Se não houver mais alocação disponível, pare em vez de habilitar um plano pago.

Antes de entrar diretamente neste curso, conclua Connect LabEx to Your Cloudflare Account. Cada VM nova do LabEx precisa de sua própria autorização do Wrangler. Os laboratórios anteriores do curso são recomendados, mas as VMs e os recursos deles nunca são reutilizados aqui.

Autorizar a VM e declarar o Worker das ferramentas

Nesta etapa, você autorizará a VM nova e declarará os recursos usados pelo Agent capaz de executar ferramentas.

Abra um terminal e entre no projeto preparado:

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

Autorize esta VM:

npx wrangler login

Abra o link exibido, aprove as permissões documentadas do Wrangler para sua conta de aprendizagem dedicada e volte ao terminal. Confirme o resultado estruturado:

npx wrangler whoami --json

Procure "loggedIn": true, confirme o nome da conta e copie o ID real dessa conta. Salve-o junto com um nome exclusivo para o Worker descartável:

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

O binding AI fornece inferência de modelos sem incorporar uma chave de API. O binding do Durable Object fornece a cada SupportToolsAgent identificado pelo nome seu próprio armazenamento SQLite. O navegador usará o nome planning; outro nome recebe uma instância separada e não pode acessar os dados de planning. Nada foi implantado ainda.

Definir os contratos das ferramentas

Nesta etapa, você descreverá exatamente quais argumentos cada ferramenta aceita.

Um esquema de ferramenta é um contrato em tempo de execução. Os tipos do TypeScript ajudam durante a compilação, mas a saída do modelo chega em tempo de execução e precisa ser verificada novamente. Crie 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

O contrato de leitura aceita apenas um nome de fila válido e o único ticket sintético. O contrato de atualização acrescenta uma prioridade definida por enum e uma revisão inteira não negativa. .strict() também rejeita campos inesperados, reduzindo a ambiguidade e impedindo que um chamador introduza instruções não suportadas na operação.

expectedRevision é uma verificação de concorrência otimista. O chamador informa qual versão observou; o servidor recusa a atualização se alguém já tiver alterado essa versão. A validação, por si só, não concede acesso: o Agent comparará separadamente queue com seu próprio nome persistente.

Implementar ferramentas com escopo no servidor

Nesta etapa, você conectará os dois esquemas a um registro local do Agent e disponibilizará a mesma implementação para o modelo e para o verificador determinístico.

Crie 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

O modelo nunca recebe acesso direto ao banco de dados. Ele propõe argumentos tipados; execute invoca o código dentro do Durable Object, onde o servidor verifica novamente o nome atual do Agent. Os dois métodos @callable() reutilizam exatamente esses caminhos de código, permitindo que o verificador teste solicitações malformadas, fora do escopo e obsoletas sem depender de escolhas não determinísticas do modelo.

O banco de dados é criado sob demanda dentro de cada Agent identificado pelo nome. INSERT OR IGNORE fornece um fixture limitado sem sobrescrever uma atualização anterior. Apenas metadados — nome da ferramenta, instância do Agent e revisão — são registrados; o texto do caso não é.

Conectar a página de chat consciente das ferramentas

Nesta etapa, você conectará a estrutura de página fornecida e exibirá a atividade das ferramentas separadamente do texto do assistente.

Crie a configuração do TypeScript e do 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

Crie 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() conecta exatamente a um Agent identificado pelo nome usando seu token de curta duração. useAgentChat() renderiza a conversa persistente e a resposta transmitida em fluxo. As partes das ferramentas são identificadas como atividade, em vez de serem incorporadas ao texto do assistente, ajudando o aluno a distinguir “o modelo solicitou uma operação” de “o modelo escreveu um texto”. O navegador ainda não pode ignorar a validação no servidor.

Compilar e comprovar os limites localmente

Nesta etapa, você compilará a aplicação e testará a implementação real das ferramentas sem consumir uma chamada de modelo.

Gere os tipos exatos do ambiente, verifique os tipos e compile os dois bundles:

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

O Wrangler deriva Cloudflare.Env dos bindings reais. Isso impede que uma interface de ambiente escrita manualmente fique diferente de wrangler.jsonc.

O Workers AI é um binding remoto, portanto o ambiente local precisa do acesso OAuth já armazenado pelo Wrangler. Passe o token somente ao processo filho e limpe imediatamente a cópia no 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ão exiba o valor OAuth temporário nem o salve em .dev.vars. A verificação independente usa um Agent identificado por um nome aleatório e chama os mesmos métodos inspectCase() e setPriority() usados pelas ferramentas do modelo. Ela comprova que:

  • a prioridade inicial é medium, na revisão 0;
  • leituras malformadas e entre filas falham;
  • uma atualização válida muda a prioridade para high, na revisão 1;
  • repetir a revisão 0 falha; e
  • outro Agent identificado por um nome diferente mantém seu registro isolado na revisão 0.

Esse teste determinístico responde se as operações são seguras. A seleção do modelo é demonstrada separadamente após a implantação, pois é probabilística.

Implantar e observar uma execução limitada de ferramenta

Nesta etapa, você fará a implantação, comprovará novamente os limites no Cloudflare e observará uma execução ativa limitada do modelo.

Implante o bundle de produção e envie a chave de assinatura gerada como um secret:

npm run deploy
npx wrangler secret bulk .dev.vars

O comando de secret envia o valor sem colocá-lo na configuração ou no bundle. Não exiba .dev.vars.

Salve a origem exata exibida pela implantação e crie um token de dez 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 a URL completa no navegador do LabEx. Envie a solicitação preparada. O status passa por submitted e streaming; os indicadores das ferramentas mostram que o modelo solicitou uma leitura e uma atualização, e a frase final confirma a prioridade high com a nova revisão.

A sessão planning assinada após a execução das ferramentas validadas de leitura e atualização

A redação exata é gerada pelo modelo e pode variar. A fila, o ticket e o registro são exemplos sintéticos. Uma frase bem-sucedida é uma evidência útil na interface, mas não é a verificação de segurança autoritativa.

Envie uma segunda solicitação: Set T-SYNTH-101 to low using expected revision 0. A revisão obsoleta não deve sobrescrever silenciosamente a revisão 1; a atividade da ferramenta deve indicar um conflito.

Uma revisão obsoleta rejeitada pelo limite da ferramenta no servidor

Execute uma verificação na nuvem nova e independente, com um nome diferente. Ela não consome outra chamada de modelo:

python3 .labex/verify.py deployed

A verificação testa os bindings e o namespace exatos implantados e repete, no Worker remoto, a rejeição de esquema, a rejeição de escopo, uma alteração de revisão bem-sucedida, a rejeição da repetição obsoleta e o isolamento entre Agents identificados por nomes diferentes.

Inspecionar e remover os recursos das ferramentas

Nesta etapa, você relacionará o comportamento em tempo de execução às visualizações de recursos do Cloudflare e excluirá somente os recursos deste laboratório.

No Cloudflare Dashboard, abra Workers & Pages, selecione o Worker exato labex-c11-s05-... e inspecione Bindings. Você deverá ver o binding do Workers AI AI e o binding do Durable Object SupportToolsAgent. Em seguida, abra Settings > Variables and Secrets para confirmar que SESSION_SIGNING_KEY está armazenado como um secret criptografado, e não como texto simples:

O Worker implantado com os bindings AI e SupportToolsAgent

Abra Durable Objects e selecione o namespace com suporte a SQL pertencente a este Worker. planning e os nomes usados pelo verificador são instâncias de objetos separadas dentro de um único namespace de classe:

O namespace SupportToolsAgent com suporte a SQL

Abra os logs ou a visualização de observabilidade do Worker e encontre tool_event. A entrada estruturada contém o nome da ferramenta, a instância do Agent e a revisão, mas não contém o resumo do caso sintético nem o texto do chat:

Um evento limitado da ferramenta de atualização nos logs do Cloudflare

Após a inspeção, crie uma migração explícita de exclusão da classe e remova o Worker exato:

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 o Worker descartável não está mais presente:

O Worker descartável de ferramentas validadas removido

Em seguida, confirme que o namespace SupportToolsAgent correspondente não está mais presente:

O namespace SupportToolsAgent descartável removido

Comprove as duas ausências enquanto esta VM ainda estiver autorizada:

python3 .labex/verify.py deleted

Excluir somente o Worker deixaria ambírio o ciclo de vida da classe com estado. A migração v2 remove explicitamente o namespace deste laboratório e seus registros sintéticos antes da verificação da exclusão do Worker.

Revogar a autorização desta VM

Nesta etapa, você revogará a autorização temporária da VM depois de comprovar a limpeza na nuvem.

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

O resultado estruturado deverá informar "loggedIn": false, ou o Wrangler poderá retornar um resultado não autenticado com código diferente de zero. O logout é deixado intencionalmente para o final: o verificador de exclusão precisa de acesso de leitura válido, enquanto a VM descartada não precisa mais dele.

Resumo

Você adicionou duas ferramentas limitadas no servidor a um AIChatAgent do Cloudflare. Você:

  • definiu contratos Zod estritos para uma leitura e uma atualização sintética;
  • manteve a autorização separada, aplicando o escopo do Agent identificado pelo nome no servidor;
  • rejeitou entradas malformadas, acesso entre filas e revisões obsoletas;
  • reutilizou a implementação exata para as ferramentas do modelo e para verificações determinísticas com callable;
  • observou uma execução limitada de ferramenta do Workers AI e logs com privacidade limitada; e
  • excluiu o namespace exato da classe SQLite e o Worker antes de sair da conta.

Esses controles tornam uma atualização sintética direta pequena e testável, mas não solicitam que uma pessoa aprove o efeito. O próximo laboratório adiciona esse limite de aprovação e torna explícitas a aprovação, a rejeição e a entrega duplicada.