All projects

Udesk Series · Sample Code

Graded Tool Registry

A tool registry graded by side effect (read / write / send) — three gates plus two guard layers, so `send` never reaches the model

Install Copy-paste · zero deps · three files views

分级 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 被拦。

代码

src/tools/tool.ts
/**
* 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;
}
src/tools/registry.ts
/**
* 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();
src/tools/orchestrator.ts
/**
* 最小编排器 —— 按「计划」顺序调 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

ESC