tools:一次工具调用的把关流水线

专栏:DeepSeek Harness · 第 9 / 14 篇
DeepSeek Harnesstools源码

模型说「帮我执行 bash ls」,到这条命令真正跑起来、结果回到模型面前,中间有十道工序。这篇把它们全部走一遍——这是 dsh 里工程密度最高的部分。

注册表:name → definition

ctx.toolsToolRuntime 服务)按作用域分层的命名表维护全部工具。注册时强校验:

// 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 是单调的:只有「拒绝」没有「翻案」,监听顺序不可能把拒绝变允许。

流水线全景

一次调用的完整工序:

图表(tools-execution-pipeline.md)

四个关键决策点,逐一细看

决策词汇——注意 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 都有配对。

十道工序的时序

图表(tools-execution-pipeline.md)

与其他模块的联系

  • 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 敢说「换一个提供方,换掉半个产品」。

← 返回文章列表