简介
经过验证的工具仍然可能执行得过于积极。如果模型提出记录变更,在写入任何数据之前,可能需要由人工检查具体操作。人工参与的批准流程会暂停工具调用,在客户端显示参数,并让人工批准或拒绝该操作。
在本实验中,你将扩展一个小型合成支持 Agent,加入一个读取工具和一个需要批准的更新操作:
lookupSupportCase仍然是服务端只读工具,无需批准即可运行。requestPriorityChange使用受支持的needsApproval选项,因此在客户端发送批准响应之前,其execute函数不会运行。- React 客户端会显示待处理操作,并调用
addToolApprovalResponse()来批准或拒绝操作。 - 持久化幂等性账本会记录操作键,因此重复交付已批准的操作时,会返回第一次的结果,而不会再次执行更新。
- 确定性探针和一个受限的 Workers AI 流程会验证待处理、拒绝、批准和重复操作的结果。
批准和授权回答的是不同问题。授权限制已签名会话可以访问哪个队列;批准则确认人工是否接受这一次具体的变更提议。幂等性解决第三个问题:网络或客户端可能会多次交付同一个已批准的操作。本实验中的所有记录都是合成数据,可以随时丢弃;实验不会连接真实的帮助台系统。
实验提供的 Shell 和短期会话令牌,可以让你专注于批准边界,而不是前端或身份验证模板代码。Workers AI 的免费额度会与账户中的其他活动共享。如果账户没有剩余额度,请停止操作,不要启用付费计划。
直接进入本课程前,请完成将 LabEx 连接到你的 Cloudflare 账户。 每台新建的 LabEx 虚拟机都需要单独完成 Wrangler 授权。建议先完成课程中的早期实验,但这里不会复用它们的虚拟机或资源。
授权虚拟机并声明批准 Worker
在本步骤中,你将授权新建的虚拟机,并声明批准 Agent 使用的资源。
打开终端,进入准备好的项目目录:
cd /home/labex/project/approval-record-changes
授权这台虚拟机:
npx wrangler login
打开显示的链接,使用专用学习账户批准文档中列出的 Wrangler 权限,然后返回终端。确认结构化结果:
npx wrangler whoami --json
查找 "loggedIn": true,确认账户名称,并复制该账户的实际 ID。使用唯一的临时 Worker 名称保存它:
ACCOUNT_ID="paste-your-confirmed-account-id"
RUN="labex-c11-s06-$(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": "ApprovalAgent", "class_name": "ApprovalAgent" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["ApprovalAgent"] }
]
}
JSON
python3 .labex/verify.py authorization
AI 绑定提供模型推理能力,无需嵌入 API 密钥。Durable Object 绑定会为每个命名的 ApprovalAgent 提供独立的 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 changePriorityInput = lookupCaseInput.extend({
priority: z.literal("high"),
operationKey: z.string()
.min(8)
.max(80)
.regex(/^[a-z0-9-]+$/, "operation key must use lowercase letters, digits or hyphens")
}).strict();
export type LookupCaseInput = z.infer<typeof lookupCaseInput>;
export type ChangePriorityInput = z.infer<typeof changePriorityInput>;
export type SupportCase = {
queue: string;
ticketId: "T-SYNTH-101";
summary: string;
priority: "low" | "medium" | "high";
revision: number;
};
export type ChangeResult = SupportCase & { duplicate: boolean; operationKey: string };
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
读取契约只接受有效的队列名称和指定的一个合成工单。变更契约将本实验限定为把优先级改为 high,并要求提供稳定的 operationKey。.strict() 还会拒绝未预期的字段,从而减少歧义,并防止调用方将不受支持的指令偷偷放入操作中。
幂等性键用于在重试过程中标识同一个逻辑操作。服务端会将第一次成功的结果保存在该键下;再次收到相同的已批准操作时,会返回已保存的结果,而不会执行第二次写入。验证并不会授予批准或访问权限——Agent 仍然会检查其持久化名称,SDK 仍然会等待人工决定。
实现批准门控和幂等效果
在本步骤中,你将保持读取操作自动执行,使用 needsApproval 暂停写入,并使批准后的操作具备幂等性。
创建 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 {
changePriorityInput,
lookupCaseInput,
parseInput,
type ChangePriorityInput,
type ChangeResult,
type LookupCaseInput,
type SupportCase
} from "./cases";
import { verifySessionRequest } from "./session-auth";
export class ApprovalAgent extends AIChatAgent<Cloudflare.Env> {
maxPersistedMessages = 12;
private ensureTables(): 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)`;
this.sql`CREATE TABLE IF NOT EXISTS approval_operations (
operation_key TEXT PRIMARY KEY,
ticket_id TEXT NOT NULL,
applied_revision INTEGER NOT NULL
)`;
}
private scopedCase(input: LookupCaseInput): SupportCase {
if (input.queue !== this.name) throw new Error("queue is outside this Agent scope");
this.ensureTables();
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));
}
private applyApprovedChange(input: unknown): ChangeResult {
const parsed: ChangePriorityInput = parseInput(changePriorityInput, input);
const current = this.scopedCase(parsed);
const prior = this.sql<{ appliedRevision: number }>`SELECT applied_revision AS appliedRevision
FROM approval_operations WHERE operation_key = ${parsed.operationKey}`[0];
if (prior) {
return { ...current, duplicate: true, operationKey: parsed.operationKey };
}
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);
this.sql`INSERT INTO approval_operations (operation_key, ticket_id, applied_revision)
VALUES (${parsed.operationKey}, ${parsed.ticketId}, ${changed.revision})`;
console.log(JSON.stringify({
event: "approval_change_applied",
instance: this.name,
operationKey: parsed.operationKey,
revision: changed.revision
}));
return { ...changed, duplicate: false, operationKey: parsed.operationKey };
}
@callable()
async verifyApprovedChange(input: unknown, proof: string): Promise<ChangeResult> {
const parsed = parseInput(changePriorityInput, input);
const payload = new TextEncoder().encode(`approval-probe:${JSON.stringify(parsed)}`);
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(this.env.SESSION_SIGNING_KEY),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
const normalized = proof.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
const signature = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
const valid = await crypto.subtle.verify("HMAC", key, signature, payload);
if (!valid) throw new Error("approval probe proof is invalid");
return this.applyApprovedChange(parsed);
}
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)
}),
requestPriorityChange: tool({
description: "Set synthetic ticket T-SYNTH-101 to high priority in the current queue. Use operation key raise-synthetic-priority.",
inputSchema: changePriorityInput,
needsApproval: true,
execute: async (input) => this.applyApprovedChange(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. Perform exactly the one action the user requests. For a lookup, call lookupSupportCase only. For a priority request, call requestPriorityChange only with operationKey raise-synthetic-priority and wait for the human decision. Never claim a denied or pending change happened. 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
模型永远不会直接访问数据库。它会提出符合类型的参数,但 needsApproval: true 会阻止 execute 运行,直到客户端提交明确的批准响应。因此,拒绝操作不会改变任何数据。只读的 callable 支持确定性检查。另一个独立的验证 callable 只有在获得由本地签名密钥生成的 HMAC 证明后,才能访问实际效果,因此普通浏览器客户端无法绕过人工批准门控。
Agent 会在不使用 await 的情况下处理每次同步 callable 调用,因此第二次交付可以看到第一次创建的账本记录。稳定的操作键会返回已经应用的结果,并将 duplicate 设为 true;它不会再次递增记录。日志中只记录事件、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 { getToolApproval, useAgentChat } from "@cloudflare/ai-chat/react";
import { useAgent } from "agents/react";
import { getToolName, isToolUIPart } from "ai";
import { Suspense } from "react";
import { createRoot } from "react-dom/client";
function ApprovalChat() {
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: "ApprovalAgent",
name: session,
host: window.location.host,
query: { token }
});
const { messages, sendMessage, addToolApprovalResponse, status, error } = useAgentChat({
agent,
autoContinueAfterToolResult: false
});
return (
<main>
<p className="eyebrow">Human approval before effect</p>
<h1>Synthetic Change Review</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 change request 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 (isToolUIPart(part)) {
const toolName = getToolName(part);
if ("approval" in part && part.state === "approval-requested") {
const approvalId = getToolApproval(part)?.id;
return (
<div className="approval" key={part.toolCallId}>
<strong>Approval required: {toolName}</strong>
<p>Review these exact synthetic arguments. No record has changed yet.</p>
<pre>{JSON.stringify(part.input, null, 2)}</pre>
<div className="approval-actions">
<button disabled={!approvalId} () => {
if (!approvalId) return;
await addToolApprovalResponse({ id: approvalId, approved: true });
sendMessage();
}}>Approve</button>
<button className="deny" disabled={!approvalId} => approvalId && addToolApprovalResponse({ id: approvalId, approved: false })}>Deny</button>
</div>
</div>
);
}
return (
<div className="tool-card" key={part.toolCallId}>
<strong>{toolName}</strong><span className="tool">{part.state}</span>
{"output" in part && part.output !== undefined && <pre>{JSON.stringify(part.output, null, 2)}</pre>}
</div>
);
}
return null;
})}
</article>
))}
</section>
<div className="quick-actions">
<button type="button" disabled={status === "streaming" || status === "submitted"} => sendMessage({ text: `Look up T-SYNTH-101 in ${session}.` })}>Check current case</button>
<span>Read-only: safe before and after a decision.</span>
</div>
<form => {
event.preventDefault();
const input = event.currentTarget.elements.namedItem("message") as HTMLInputElement;
const text = input.value.trim();
if (!text) return;
sendMessage({ text });
}}>
<input name="message" defaultValue={`Request high priority for T-SYNTH-101 in ${session} with operation key raise-synthetic-priority.`} maxLength={220} aria-label="Change 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 approval session…</p></main>}><ApprovalChat /></Suspense>
);
TSX
python3 .labex/verify.py client
useAgent() 会使用短期令牌连接到一个确定名称的 Agent。只读按钮和变更表单会有意创建两个独立的会话轮次:每个轮次只有一个目的,因此学习者可以观察决定前后的状态,而不会把已经完成的读取工具混入暂停中的写入操作。useAgentChat() 会提供批准响应辅助函数。Agents 客户端会通知服务端该决定;这里禁用了客户端自动继续,因此同一个已批准的工具调用不会被再次提交。工具结果和一次全新的读取,比额外生成的一句模型摘要更能提供可靠证据。isToolUIPart() 用于区分工具操作和普通助手文本,getToolApproval() 则通过 SDK 支持的接口读取批准对象。批准 ID 会将人工决定绑定到这一次具体的工具调用;浏览器不会直接调用数据库方法。JSON 卡片会显示读取结果、提议参数和最终写入结果,同时不会暴露账户凭据。
在本地构建并验证边界
在本步骤中,你将编译应用,并在不消耗模型调用的情况下测试实际工具实现。
生成准确的环境类型,检查类型并构建两个 Bundle:
npx wrangler types
npm run check
npm run build
python3 .labex/verify.py build
Wrangler 会根据实际绑定生成 Cloudflare.Env。这样可以避免手写的环境接口逐渐偏离 wrangler.jsonc。
Workers AI 是远程绑定,因此本地运行时需要使用 Wrangler 已经保存的 OAuth 访问权限。只将它传递给子进程,然后立即清除 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
不要打印临时 OAuth 值,也不要将其保存到 .dev.vars。独立探针会使用随机命名的 Agent、只读的 inspectCase() 方法,以及由本地签名密钥生成的仅用于测试的 HMAC 证明。该证明让验证器可以测试与已批准模型工具相同的私有效果,而无需发布能够绕过批准的 callable。它可以独立于模型行为,验证该效果在服务端的安全性:
- 初始优先级为
medium,修订号为0; - 跨队列读取会失败;
- 一次批准后的效果调用会将优先级改为
high,修订号变为1; - 使用相同操作键时会返回
duplicate: true,并保持修订号为1;以及 - 另一个命名的 Agent 仍然保留隔离的修订号为
0的记录。
这个确定性测试可以验证重复交付是否安全。浏览器流程则单独验证:在批准之前以及被拒绝时,受支持的 SDK 门控都会阻止该效果执行。
部署并测试拒绝、批准和重放
在本步骤中,你将完成部署,然后观察同一个变更提议保持待处理、被拒绝、只批准一次,并在重复提交时仍然安全。
部署生产 Bundle,并将生成的签名密钥上传为密钥:
npm run deploy
npx wrangler secret bulk .dev.vars
密钥命令会在不将值放入配置或 Bundle 的情况下发送它。不要打印 .dev.vars。
保存部署输出的准确源站地址,然后为 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"
在 LabEx 浏览器中打开完整 URL。先选择 Check current case:只读结果应显示优先级为 medium,修订号为 0。然后发送预先填写的变更请求。这个第二个、单一目的的会话轮次会停在 Approval required。在做出决定之前,服务端的 execute 函数尚未运行:

选择 Deny。工具会变为拒绝状态,Agent 不得声称更新已经发生。再次选择 Check current case:新的读取结果仍应显示优先级为 medium,修订号为 0,这证明被拒绝的执行没有产生任何变更。再次发送预先填写的变更请求,创建新的批准卡片:

在这次新的请求上选择 Approve。受支持的客户端会将批准 ID 发回 Agent,服务端执行一次操作,结果显示优先级为 high、修订号为 1,并且 duplicate: false。选择 Check current case,独立观察相同的修订号:

第三次发送相同的预先填写的变更请求并批准它。持久化账本会识别 raise-synthetic-priority;结果会显示 duplicate: true。再次选择 Check current case,记录应仍然保持修订号 1:

助手的具体句子由模型生成,可能有所不同。批准卡片的状态、工具结果字段和记录修订号才是有用的证据。队列、工单和记录都是合成示例。
运行一个全新的、使用独立名称的云端探针。它不会消耗另一个模型调用:
python3 .labex/verify.py deployed
该探针会检查已部署的准确绑定和命名空间,然后针对一个独立命名的远程 Agent,重复测试作用域拒绝、一次成功变更、相同键的重放安全性,以及命名 Agent 的隔离性。它会在不消耗另一个模型调用的情况下测试幂等效果;浏览器流程才用于验证 SDK 的批准门控。
检查并删除批准资源
在本步骤中,你将把运行时行为与 Cloudflare 的资源视图对应起来,然后只删除本实验创建的资源。
在 Cloudflare Dashboard 中打开 Workers & Pages,选择准确名称为 labex-c11-s06-... 的 Worker,并检查 Bindings。你应该能看到 AI Workers AI 绑定和 ApprovalAgent Durable Object 绑定。然后打开 Settings > Variables and Secrets,确认 SESSION_SIGNING_KEY 以加密密钥的形式存储,而不是明文:

打开 Durable Objects,选择由此 Worker 所属的、由 SQL 支持的命名空间。planning 和验证器使用的名称,是同一个类命名空间中的不同对象实例:

打开 Worker 的日志或可观测性视图,查找 approval_change_applied。一个已接受的逻辑操作会产生一条结构化日志记录。记录包含 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': ['ApprovalAgent']})
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 已不存在:

然后确认其 ApprovalAgent 命名空间已不存在:

在这台虚拟机仍然获得授权时,验证这两个资源都已不存在:
python3 .labex/verify.py deleted
如果只删除 Worker,有状态类的生命周期就仍然不明确。迁移 v2 会在验证删除 Worker 之前,明确删除本实验的命名空间、合成记录和幂等性账本。
撤销这台虚拟机的授权
在本步骤中,你将确认云端清理完成后,撤销临时虚拟机的授权。
npx wrangler logout
npx wrangler whoami --json || true
结构化结果应报告 "loggedIn": false,或者 Wrangler 可能返回未通过身份验证的非零结果。退出登录必须放在最后:删除验证器需要有效的读取权限,而这台即将丢弃的虚拟机不再需要权限。
总结
你已在 Cloudflare AIChatAgent 的记录变更前加入了受支持的人工决定流程。你完成了以下操作:
- 保持读取操作自动执行,同时使用
needsApproval标记写入操作; - 显示
approval-requested部分,并提交明确的批准或拒绝响应; - 证明待处理和被拒绝的操作不会改变合成记录;
- 保存持久化幂等性键,使批准后的重试返回修订号
1,而不会再次写入; - 在服务端强制执行命名 Agent 作用域,从而将授权与批准分离;
- 观察受隐私限制的批准事件;以及
- 在退出登录前删除准确的 SQLite 类命名空间和 Worker。
现在,人工批准已经明确且可审计,而幂等性账本可以保护实际效果免受重复交付的影响。下一个实验将转向另一个方向:通过 MCP 发布合成只读能力,届时新的重点将是发现和传输。



