tools:一次工具调用的把关流水线
专栏:DeepSeek Harness · 第 9 / 14 篇模型说「帮我执行 bash ls」,到这条命令真正跑起来、结果回到模型面前,中间有十道工序。这篇把它们全部走一遍——这是 dsh 里工程密度最高的部分。
注册表:name → definition
ctx.tools(ToolRuntime 服务)按作用域分层的命名表维护全部工具。注册时强校验:
// packages/core/tools/src/index.ts:1028-1053(节选)
register(definition: ToolDefinition): () => void {
const name = definition.name
const output = (definition as Partial<ToolDefinition>).output
if (output === undefined || typeof output !== 'object'
|| typeof output.render !== 'function'
|| (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function')) {
throw new TypeError(`tool "${name}" must declare output { schema, render, presentationMeta? }`)
}
assertSupportedJsonSchema(output.schema)
// 保留名:任何 agent 都可能选择代码模式,这个名字永远留给传输层
if (name === RUN_CODE_NAME) {
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved ... cannot be registered or shadowed`)
}
return this.layers.effect(
this.ctx,
layer => layer.tools.insert(name, definition),
{ label: 'tools.register()' },
)
}
模型可见的投影 schemas() 白名单只含 name/description/parameters——execute、超时、展示函数等永不上协议。多个作用域的限制(restriction)取交集,且 guards 是单调的:只有「拒绝」没有「翻案」,监听顺序不可能把拒绝变允许。
流水线全景
一次调用的完整工序:
四个关键决策点,逐一细看
决策词汇——注意 ask 的语义写在类型注释里,且参数不可改写(日志、审计、UI、执行看到的必须一致):
// packages/core/tools/src/index.ts:575-584
export type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string }
| { kind: 'ask'; reason?: string }
ask → 审批——注册表把 ask 折算成审批请求,四种结果的拒绝理由彼此不同,让模型能区分「人类说不」和「审批通道不存在」:
// packages/core/tools/src/index.ts:1680-1719(节选)
private async serviceAsk(exec: ToolExecution, ask: ...): Promise<ToolAskResolution> {
const approval = this.ctx.get('approval')
if (approval === undefined) {
return { decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval (not yet supported)` }, ... }
}
if (exec.agent === undefined) {
return { decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent ...` }, ... }
}
const outcome = await approval.request({
agent: exec.agent, toolName: exec.name, callId: exec.callId,
...ask.reason !== undefined ? { reason: ask.reason } : {},
signal: exec.signal,
})
switch (outcome) {
case 'allowed-once': return { decision: { kind: 'allow' }, ... }
case 'rejected': return { decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }, ... }
case 'cancelled': return { decision: { kind: 'deny', reason: `approval ... was cancelled` }, approvalCancelled: true }
case 'unavailable': return { decision: { kind: 'deny', reason: `... no approval channel is available` }, ... }
}
}
环绕分发与信号熔合——tools/execute 是唯一能换 exec.signal 的地方(超时插件就靠它),但注册表在进入工具体之前把调用方信号重新熔合,wrapper 无法让调用脱离调用方的取消:
// packages/core/tools/src/index.ts:1523-1551(节选)
private async dispatchToolBody(exec: MutableToolRunContext): Promise<ToolExecutionResult> {
const state = this.cancellationStates.get(exec)
const wrapperSignal = exec.signal
// 把 caller signal 与 wrapper 换上的信号熔合:wrapper 只能加严,不能摘除
const fused = fuseToolSignals(state.callerSignal, wrapperSignal)
const signal = fused.signal
if (isAborted(signal)) {
fused.dispose()
return toolAbortedBeforeDispatchResult()
}
exec.signal = signal
try {
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
if (!tool) throw new ToolNotFoundError(exec.name)
state.bodyInvoked = true
const returned = await tool.execute(exec.arguments, exec)
const result = this.createSuccessResult(exec, tool, returned)
return isAborted(signal) ? toolAbortedResult(result) : result
} catch (error: unknown) {
return toolErrorResult(error) // 工具抛错 = isError 结果,不终止轮次
} finally {
fused.dispose()
exec.signal = wrapperSignal
}
}
post-execute 改写——唯一的「结果变换」点:accept 可换 content 或换 value(不可同换,换 value 要重过 schema 校验),block 把反馈变成纠偏用的 isError 结果,还可以附 additionalContexts:
// packages/core/tools/src/index.ts:1733-1772(节选)
private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
const decision = await this.ctx.waterfall(
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
)
if (decision.kind === 'block') {
const message = failureMessageFromContent(decision.feedback)
return this.markCanonical(exec, {
content: decision.feedback, isError: true, error: { message }, ...
})
}
if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) {
throw new TypeError('tools/post-execute accept decision cannot replace both value and content')
}
// ……value/content 替换与 additionalContexts 合并……
}
并发:按模型顺序提交
模型一次可以要多个工具。调度规则:isConcurrencySafe 为 true 的进有界滚动池(上限默认 10),否则是屏障(一次一个);每次启动前重新分类(注册表变化可即时制造新屏障);但持久结果与附加上下文永远按模型顺序提交——日志里 tool/result 的顺序和模型发出 tool/call 的顺序一致。跳过的调用也会合成占位结果,保证每个 callId 都有配对。
十道工序的时序
与其他模块的联系
- agent-loop(第 7 篇):调度器按
executionMode分类并发,tool/result按模型序提交; - 审批(第 12 篇):pre-execute 的 ask 经
ctx.approval折算,四种拒绝理由彼此不同; - 沙箱(第 10 篇):bash 执行器的 argv 经
ctx.sandbox.confine包装——把关链的第七环; - 会话(第 6 篇):tool/call 先落日志拿 seq,tool/result 回指该 seq——执行前留痕。
把五个扩展点记成一句话:「pre 管准入,guard 管终局,execute 管包裹,post 管改写,result 只旁观」。第 14 篇写策略插件时,选点就看这句。
至此核心主干四件套读完(session / loop / prompt / tools)。下一篇转设计哲学:为什么 dsh 敢说「换一个提供方,换掉半个产品」。