Udesk 系列 · 示范代码
分级 tool 注册表
按副作用分 read / write / send 的 tool 注册表 —— 三道闸 + 两层防护,send 永远到不了 LLM 手上
分级 tool 注册表
把已有的能力函数包成统一、可枚举、可编排的 tool,并给每个 tool 标一个副作用等级:
| 等级 | 含义 | 能自主调吗 |
|---|---|---|
read | 纯读:查数据、检索、抓取 | ✅ |
write | 改本地状态:填表单、提交 | 固定流程里可以,LLM 自主调不行 |
send | 对外发出去:发消息、发邮件 | ❌ 永远人确认 |
分级依据是两个具体问题——做完之后能撤销吗?外部的人会立刻看到吗?——而不是「危险程度」这种模糊感觉。
- 零依赖,三个文件,TypeScript
- 注册表
invoke过三道闸:能力未授权 / 场景不匹配 /send+ 自主编排 - 给 LLM 的 function 清单里根本不含
send类(模型不会用它不知道存在的东西) - 只读出口执行前再查一次副作用等级,防模型幻觉出
ticket.submit这种名字 - 自带 wire 名映射:OpenAI 规范的 function name 不允许点号,严格的网关会直接 400
- 附一个最小编排器:固定计划、结果串联、
stopIf提前结束
📖 设计取舍、以及「固定流程可以自主提交」和「LLM 可以自主调用提交」的区别 → 给 agent 的 tool 分级
怎么用
1. 注册一个 tool(薄 wrapper,不改被包的函数):
import { toolRegistry } from './tools/registry';
toolRegistry.register({ name: 'kb.search', // <域>.<能力> domain: 'kb', title: '检索知识库', description: '按关键词检索知识库,返回最相关的若干条', // ← 这句是给 LLM 看的 inputSchema: { type: 'object', properties: { query: { type: 'string', description: '检索关键词' } }, required: ['query'], }, sideEffect: 'read', featureFlag: 'knowledgeBase', // 可选:未授权则注册表不暴露它 run: async ({ query }) => searchKnowledge(query), // ← 你已有的能力函数});2. 人触发(快捷键、按钮):
const r = await toolRegistry.invoke('kb.search', { query: '发票' }, { flags, scope });3. 交给 LLM(只读):
// 喂给模型的清单:已过滤未授权 / 不在当前场景 / 所有 send 类const tools = toolRegistry.toFunctionSchemas({ flags, scope, autonomous: true });
// 模型回调时走只读出口,它会再查一次副作用等级const r = await toolRegistry.invokeReadOnly(call.name, JSON.parse(call.arguments), { flags, scope });4. 跑一条固定计划:
const plan = { name: '看这个会话在问什么', steps: [ { tool: 'session.fetch', buildInput: (c) => (c.seed.id ? { id: c.seed.id } : undefined) }, { tool: 'kb.search', buildInput: (c) => { const turns = c.byTool['session.fetch']?.data; const last = [...(turns ?? [])].reverse().find((t) => t.role === 'customer'); return last ? { query: last.text.slice(0, 200) } : undefined; // undefined = 跳过本步 } }, ],};await runPlan(plan, { id: 42 }, { flags, scope });⚠
send类 tool 的run()里不该有发送代码。 它只准备 payload、返回requiresConfirm,真发在上层人确认之后。这不是约定而是结构——就算绕过所有闸门调用它,也发不出去。
验证
三个文件都过了 tsc --strict,另有 18 项行为测试覆盖:三道闸各自的放行与拒绝、send 在自主语境被拒且回 requiresConfirm、自主清单里不含 send、wire 名往返、只读出口拒绝 write、幻觉名返回结构化 error 而非抛异常、run 抛错被转成结果、编排器结果串联 / 缺 seed 整链跳过 / stopIf 提前结束 / 编排器里的 send 被拦。
代码
/** * Tool 层核心接口 —— 把已有的能力函数包装成统一、可枚举、可编排的 tool。 * * 只加一层薄 wrapper + 元数据(schema / 描述 / 副作用标注),**不改被包的函数**。 */
/** * 副作用等级 —— 决定 agent / LLM 能否自主调用。 * * 分级依据是两个具体问题,不是「危险程度」这种模糊感觉: * 做完之后能撤销吗?外部的人会立刻看到吗? * * - `read` 纯读:查数据、检索、抓取 → 可自主 * - `write` 改本地状态:填表单、提交 → 固定流程里可自主;**LLM 自主调不行** * - `send` 对外发出去:给客户发消息、发邮件 → **永远人确认**,LLM 绝不可自主 */export type SideEffect = 'read' | 'write' | 'send';
/** 极简 JSON Schema 子集:够描述 function-call 参数即可,不引入依赖。 */export interface JsonSchema { type: 'object'; properties: Record< string, { type: 'string' | 'number' | 'boolean' | 'array' | 'object'; description?: string; enum?: readonly (string | number)[]; default?: unknown; } >; required?: string[];}
export interface ToolResult<O = unknown> { ok: boolean; data?: O; error?: string; /** * ★ `send` 类 tool 的 run() **永不真发** —— 它只准备 payload、返回 requiresConfirm, * 由上层界面拿给人确认之后再走真发分支。 * 这不是约定而是结构:就算绕过所有闸门调用它,也发不出去。 */ requiresConfirm?: boolean; confirmPayload?: unknown;}
export interface Tool<I = any, O = any> { /** 唯一名,建议 `<域>.<能力>`。也是给 LLM 的 function name(出口会做 wire 映射) */ name: string; /** 功能域,用来分组和过滤 */ domain: string; /** 给人看的短名(面板 / 日志) */ title: string; /** 给 LLM 看的自然语言描述:说清楚做什么、什么时候该用 */ description: string; inputSchema: JsonSchema; sideEffect: SideEffect; /** 仅在这些页面 / 场景可用。省略 = 任意 */ scopes?: string[]; /** 该能力未授权则注册表不暴露此 tool。省略 = 不 gate */ featureFlag?: string; run(input: I): Promise<ToolResult<O>>;}
/** 给 LLM 的 function schema 形态(OpenAI 风格)。 */export interface FunctionSchema { name: string; description: string; parameters: JsonSchema;}
/** 调用上下文 —— 注册表据此做 gating 与安全闸。 */export interface InvokeCtx { /** 当前场景标识,与 Tool.scopes 比对。null = 未知 */ scope?: string | null; /** 当前身份的能力开关,与 Tool.featureFlag 比对 */ flags?: Record<string, boolean>; /** * ★ 是否 LLM 自主编排(true = 模型自己决定调什么,非人主动触发)。 * autonomous 时注册表拒绝 send 类,强制回到人确认路径。 */ autonomous?: boolean;}/** * ToolRegistry —— tool 的注册 / 枚举 / 统一调用入口。 * * 统一 invoke 在这里过三道闸: * 1. featureFlag 未授权 → 拒绝 * 2. scope 不匹配 → 拒绝 * 3. sideEffect==='send' 且 autonomous → 拒绝(强制回人确认路径) */
import type { Tool, ToolResult, FunctionSchema, InvokeCtx, SideEffect,} from './tool';
/* ------------------------------------------------------------------ * * wire 名映射 * * OpenAI 规范里 function name 只允许 ^[a-zA-Z0-9_-]{1,64}$ —— **点号不合法**。 * 严格校验的网关会直接 400,而且不告诉你是哪个字段错。 * * 所以在出口做一层映射,**注册表内部的命名一个字不动**(那套名字通常已经被 * 快捷键、编排器、调试钩子广泛引用,全局重命名的代价远大于加两行映射)。 * ------------------------------------------------------------------ */export const toWireName = (name: string): string => name.replace(/\./g, '__');export const fromWireName = (name: string): string => name.replace(/__/g, '.');
export class ToolRegistry { private tools = new Map<string, Tool>();
register(tool: Tool): this { if (this.tools.has(tool.name)) { console.warn(`[tools] 重复注册,覆盖:${tool.name}`); } this.tools.set(tool.name, tool); return this; }
get(name: string): Tool | undefined { // 两种名字都认:内部名和 wire 名 return this.tools.get(name) ?? this.tools.get(fromWireName(name)); }
/** 列出 tool,可按域 / 副作用 / 场景过滤。 */ list(opts?: { domain?: string; sideEffect?: SideEffect; scope?: string | null }): Tool[] { let out = [...this.tools.values()]; if (opts?.domain) out = out.filter((t) => t.domain === opts.domain); if (opts?.sideEffect) out = out.filter((t) => t.sideEffect === opts.sideEffect); if (opts?.scope != null) { out = out.filter((t) => !t.scopes || t.scopes.includes(opts.scope as string)); } return out; }
/** * 给 LLM 的 function schema 数组(OpenAI 风格,名字已转 wire 形态)。 * * ★ 防护第一层:autonomous 时 send 类**根本不进清单**。 * 模型不会去用它不知道存在的东西——这是最有效的一层。 */ toFunctionSchemas(ctx: InvokeCtx): FunctionSchema[] { return [...this.tools.values()] .filter((t) => this.isAvailable(t, ctx).ok) .filter((t) => !(ctx.autonomous && t.sideEffect === 'send')) .map((t) => ({ name: toWireName(t.name), description: t.description, parameters: t.inputSchema, })); }
/** * 统一调用入口:过三道闸后调 tool.run。 * **任何阶段被拒都返回结构化 error,绝不向外抛** —— 调用方(尤其是 LLM 循环) * 需要的是一个能塞回对话的结果,不是一个异常。 */ async invoke(name: string, input: unknown, ctx: InvokeCtx = {}): Promise<ToolResult> { const tool = this.get(name); if (!tool) return { ok: false, error: `未知 tool:${name}` };
const gate = this.isAvailable(tool, ctx); if (!gate.ok) return { ok: false, error: gate.error };
// ★ 闸 3:send + 自主编排 → 绝不放行 if (tool.sideEffect === 'send' && ctx.autonomous) { return { ok: false, requiresConfirm: true, error: `${tool.name} 会对外发送,禁止自主调用 —— 须人确认`, }; }
try { return await tool.run(input); } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; } }
/** * ★ 防护第二层:给 LLM 循环用的出口,**执行前再查一次副作用等级**。 * * 防的是模型幻觉出一个不存在的名字——它可能猜出 `ticket.submit` 这种看起来 * 很合理的名字,哪怕你从没给过它。 */ async invokeReadOnly(wireName: string, input: unknown, ctx: InvokeCtx = {}): Promise<ToolResult> { const tool = this.get(wireName); if (!tool) return { ok: false, error: `未知 tool:${wireName}` }; if (tool.sideEffect !== 'read') { return { ok: false, error: `${tool.name} 不是只读 tool,禁止自主调用` }; } return this.invoke(tool.name, input, { ...ctx, autonomous: true }); }
private isAvailable(tool: Tool, ctx: InvokeCtx): { ok: boolean; error?: string } { if (tool.featureFlag && !ctx.flags?.[tool.featureFlag]) { return { ok: false, error: `功能未授权:${tool.featureFlag}` }; } if (tool.scopes && (ctx.scope == null || !tool.scopes.includes(ctx.scope))) { return { ok: false, error: `不在适用场景(需 ${tool.scopes.join('/')},当前 ${ctx.scope ?? 'null'})`, }; } return { ok: true }; }}
/** 全局单例。各域在自己的 `<域>-tools.ts` 里注册。 */export const toolRegistry = new ToolRegistry();/** * 最小编排器 —— 按「计划」顺序调 tool,把每步结果喂给下一步。 * * 计划是**手写的固定链**:可回归、可单测、出问题能精确定位到哪一步。 * LLM 生成的动态计划也是同样的结构,但那条路要先解决可预测性,见 README。 * * 安全:编排器一律以 **autonomous 语境**调用 → 注册表会拒绝 send 类。 * 要对外发送必须由上层人确认路径单独触发,编排器不碰 send。 */
import type { ToolResult } from './tool';import { toolRegistry } from './registry';
export interface PlanContext { /** 计划的初始输入 */ seed: Record<string, unknown>; /** 已完成步骤的结果,按 tool 名索引(同名后者覆盖前者) */ byTool: Record<string, ToolResult | undefined>; /** 按序号索引 */ bySeq: (StepOutcome | undefined)[];}
export interface AgentStep { tool: string; /** 静态输入;与 buildInput 二选一(buildInput 优先) */ input?: unknown; /** * 动态输入:读之前步骤的结果算出本步 input。 * 返回 undefined = **跳过本步**(前置条件不满足)。 */ buildInput?: (ctx: PlanContext) => unknown | undefined; /** 拿到本步结果后判断是否提前结束整个计划 */ stopIf?: (result: ToolResult, ctx: PlanContext) => boolean;}
export interface AgentPlan { name: string; steps: AgentStep[];}
export interface StepOutcome { tool: string; skipped: boolean; result?: ToolResult;}
export interface PlanRunResult { ok: boolean; planName: string; outcomes: StepOutcome[]; /** 若因 stopIf 提前结束,记在哪一步停的 */ stoppedAt?: number;}
/** * 执行一个计划。 * * 任一步返回 ok:false **不一定中止** —— 由计划自己的 stopIf 决定。 * 默认继续并记录失败:一步查不到东西,后面几步往往仍然有意义。 */export async function runPlan( plan: AgentPlan, seed: Record<string, unknown> = {}, ctxBase: { scope?: string | null; flags?: Record<string, boolean> } = {},): Promise<PlanRunResult> { const ctx: PlanContext = { seed, byTool: {}, bySeq: [] }; const outcomes: StepOutcome[] = [];
for (let i = 0; i < plan.steps.length; i++) { const step = plan.steps[i]; const input = step.buildInput ? step.buildInput(ctx) : step.input;
if (input === undefined && step.buildInput) { const oc: StepOutcome = { tool: step.tool, skipped: true }; outcomes.push(oc); ctx.bySeq[i] = oc; continue; }
// autonomous:true → 注册表拒绝 send 类;read / write 正常 const result = await toolRegistry.invoke(step.tool, input ?? {}, { ...ctxBase, autonomous: true, });
const oc: StepOutcome = { tool: step.tool, skipped: false, result }; outcomes.push(oc); ctx.bySeq[i] = oc; ctx.byTool[step.tool] = result;
if (step.stopIf?.(result, ctx)) { return { ok: true, planName: plan.name, outcomes, stoppedAt: i }; } }
return { ok: !outcomes.some((o) => o.result && !o.result.ok), planName: plan.name, outcomes, };}设计与踩坑 → 给 agent 的 tool 分级:read / write / send,以及为什么 send 永远不给 LLM