深色模式
第四步 · 工具注册表与执行管线
目标:实现工具注册表 + 四道闸门。对应文件:
final-project/packages/mini-harness/src/agent/tools.ts、agent/system-prompt.ts。对照原理篇 06与源码拆解 06。
4.1 工具定义:三个世界
ts
export interface ToolDefinition {
name: string
description: string // 模型面:进入提示词装配
parameters: Record<string, unknown> // 模型面:JSON Schema
execute(args: unknown, exec: ToolExecution): Promise<unknown> // 执行面
render(args: unknown, value: unknown): ContentBlock[] // 渲染面
}system-prompt.ts 的 assemble() 把注册表里的工具投影成模型可见的 schema 列表:
ts
assemble(): PromptAssembly {
const tools = this.ctx.get<ToolRegistry>('tools')?.schemas() ?? []
return this.ctx.waterfall('system-prompt/assemble', base, () => base)
}提示片段也在这里:section() 注册有序片段(order 升序),render() 做 插值。dsh 的 persona、时间上下文、工具引导提示都是这个机制的实例——mini-harness 的 persona 就是一行 section 注册(见 apps/server/src/bootstrap.ts)。
4.2 执行管线:四道闸门(约 130 行)
ts
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
// ① 单调守卫:任何插件都能最终拒绝(返回字符串 = 拒绝原因)
for (const guard of this.guards) {
const reason = guard(exec)
if (reason) return this.denied(exec, reason)
}
// ② tools/pre-execute 瀑布:allow / deny / ask
const decision = await this.ctx.waterfall('tools/pre-execute', exec,
async () => ({ kind: 'allow' }))
if (decision.kind === 'deny') return this.denied(exec, decision.reason)
if (decision.kind === 'ask') {
// 教学版没有交互审批面:ask 降级为 deny(真实 dsh 由 user-approval 插件接管)
return this.denied(exec, `需要用户审批:${decision.reason ?? ''}`)
}
// ③ tools/execute 瀑布:环绕执行(可换 signal、加超时)
const result = await this.ctx.waterfall('tools/execute', exec, async () => {
try {
const value = await def.execute(exec.arguments, exec)
return { isError: false, value, content: def.render(exec.arguments, value) }
} catch (err) {
return { isError: true, error: { … }, content: [] } // 失败也是信息
}
})
// ④ tools/post-execute 瀑布:accept / block(可替换规范值)
const post = await this.ctx.waterfall('tools/post-execute', exec, result,
async () => ({ kind: 'accept' }))
// …
// ⑤ tools/result 只读通知
this.ctx.emit('tools/result', exec, final)
return final
}执行失败是返回值,不是异常
execute 抛错 → 管线把它变成 { isError: true, error: { name, message } } 返回。agent-loop 再把它写成 tool/result 落盘。模型会收到"工具失败了"的事实——失败也是信息(原理篇 6.5)。
双层输出
value(规范值)与 content(渲染块)成对出现:前者进模型上下文,后者进日志与 UI。bootstrap.ts 里的 echo 工具是最小示范:
ts
async execute(args) {
const { text, repeat = 1 } = args as { text: string; repeat?: number }
return text.repeat(Math.max(1, Math.min(repeat, 10)))
},
render(_args, value) {
return [{ type: 'text', text: String(value) }]
},4.3 内置策略示例:拒绝 calc(999)
apps/server/src/bootstrap.ts 里挂了一个 pre-execute 审批钩子:
ts
c.on('tools/pre-execute', async (exec, next) => {
if (exec.name === 'calc' && JSON.stringify(exec.arguments).includes('999')) {
return { kind: 'deny', reason: '教学策略:拒绝 calc(999)' } // 不调 next() = 否决
}
return next() // 只读观察必须委托
})在前端让 mock 模型调用 calc 表达式中带 999 的调用,就能在事件面板看到被拒的 tool/result。策略与机制分离在这里第一次有了你的亲手实现。
4.4 与真实 dsh 的差异
| 差异 | 原因 |
|---|---|
无 defineTool 的 TS 类型推导 | 教学版 execute(args: unknown) 手动断言,推导是纯编译期糖 |
无 isConcurrencySafe / 并发调度 | 教学版工具串行执行;并发池是调度器主题 |
无 restrict 作用域过滤 | 无 isolate 作用域,注册即全局 |
| ask 降级为 deny | 审批面属于产品层(真实 dsh 由 user-approval 插件挂起等待) |
小练习:实现一个 post-execute 改写器
写一个插件监听 tools/post-execute:当 echo 的返回值包含"secret"时,替换为 { kind: 'accept', value: '已脱敏' }。观察事件面板里 tool/result 的内容变化,以及模型(mock)最终引用的结果。提示:post 决策里 value 替换规范值,管线会用 render 重新渲染。