DeepSeek Harness 的工具调用管线:从 pre-execute 到结果落盘

2026-08-16

模型吐出一个 tool-call 块,本机跑一段代码,结果回给模型——听起来是三步。但在 deepseek-ai/deepseek-harness 这个仓库里,这件事被拆成了十九个节点,中间还夹着三条 waterfall、一层单调 guard、一次审批,以及注册表的一层外层归一化。

先把限定条件说在前面:截至 2026-08-16 我们采集时,这个仓库建立于 2026-08-13,根 package.json 的版本是 0.1.0-rc.5,GitHub Releases 一个都没有,README 自述处于开发者预览阶段并明写未来会有破坏兼容性的变更。所以下面提到的每一个事件名、参数名、默认值,都可能在你读到时已经变了。本文全部内容来自仓库源码与文档的实读,我们没有安装、也没有运行过它。

这张图本身是生成物

管线的权威描述在 docs/tool-execution-pipeline.md。这个文件全长 62 行,文件头两行标着由 scripts/gen-doc-graphs.ts 生成、不要手工编辑,正文主体是一张 Mermaid 流程图。它自己在末尾说明了维护模式:这是一张 curated 的流程图,精确的工具 schema 与事件签名住在生成的目录里。

页首那句话是整篇的骨架,原文是:tools/pre-execute waterfall 先跑,monotonic guards 接着跑,然后是 tools/executetools/post-execute 两条 waterfall,这三条 waterfall 都可能改写一次调用;由 definition 拥有的 finalizeContenttools/result 在这之后才跑。

图里一共十九个节点,其中 denied 是被拒调用的汇聚点(工具体被跳过),其余按原文顺序是这样一串:model(assistant 消息里出现 tool-call 块)→ toolCall(会话事件 tool/call,在执行前记录)→ presentCall(UI 上的 pending 卡片)→ pretools/pre-execute,注释标的是 hooks、permission、sandbox)→ guards(已注册的 monotonic guards,只能 deny 或弃权,执行身份受保护)→ approvalctx.approval 的一次性询问)→ aroundtools/execute,围绕 dispatch 做 timeout、retry、metrics)→ toolBody(工具自己的 execute())→ fsGatefs/write-intentfs/edit-intent,仅 tool-fs 的写操作)→ owned(工具自己拥有的会话事件:todo/writefs/observedhook/invokedhook/resulttool/code-dispatch)→ posttools/post-execute,可 accept、block、replace、附加上下文)→ normalized(注册表外层归一化)→ finalizeToolDefinition.finalizeContent)→ finaltools/result 同步通知)→ context(active-batch 的 additionalContexts 按 FIFO 注入)→ toolResult(会话事件 tool/result)→ allResults(整批结算)→ presentResult(UI 完成卡片)。

反直觉的一处:审批插在 guard 前面

如果只看那句话的顺序,很容易以为「先 pre-execute,再 guard,再执行」。但把图里的边读一遍,顺序不是这样。

docs/tool-execution-pipeline.md 的分支边写的是:pre --allow--> guardspre --deny--> deniedpre --ask--> approval,而 approval --allowed-once--> guardsapproval --rejected, cancelled, unavailable--> denied。也就是说,ask 这条分支从 pre 直接拐去审批,审批通过之后才回到 guards。同一文件里还有一句写死了这层关系:ctx.approval 在 monotonic guards 之前解析 asks。

这在源码里能对上位置。packages/core/tools/src/index.tsprepareExecution() 里,先跑 tools/pre-execute waterfall(这条 waterfall 的默认值是 { kind: 'allow' }),若结果是 ask 就调 serviceAsk,随后才去算 guard 的拒绝理由。

为什么这个顺序值得单独拎出来说?因为它意味着:用户在弹窗上点了「允许一次」,这次调用并没有被放行,它还要再过一遍 guard。而 guard 的类型签名是 ToolGuard = (execution: Readonly<ToolExecution>) => string | undefineddocs/subsystems/tools.md 里明说这个返回类型故意没有 allow 分支:返回 undefined 是保留 waterfall 已经做出的决定,返回字符串只能降低权限。文档给的理由原话是,监听器的注册顺序没法把一次拒绝重新变回许可。

顺带一提,非测试源码里真正调用 ctx.tools.guard( 的地方,我们只数出 1 处,在 packages/subagent/subagent-in-process-driver/src/structured.ts

ask 是怎么落地的:五条分支,全部 fail-closed

serviceAskpackages/core/tools/src/index.ts 里,它把审批服务的四种 outcome 映射成管线决策,另外还有两种「压根问不出去」的情况:

情况结果reason 原文(截取)
ctx.get('approval') 为 undefineddenyrequires approval (not yet supported)
exec.agent 为 undefineddenyrequires approval, but the call has no agent to route it through
outcome allowed-onceallow
outcome rejecteddenythe user rejected tool "<name>"
outcome cancelleddeny(另置 approvalCancelled: trueapproval for tool "<name>" was cancelled
outcome unavailabledenyrequires approval, but no approval channel is available

审批子系统那边配套的是一套封闭词表:ApprovalOutcome 只有 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' 四个值,docs/subsystems/approval.md 写明「allowed-once 只授予被问到的那一个动作」,并且缺失的、非属主的、抛错的、不合规的 answerer 一律变成 unavailable 而不是把门打开。源码里也确实做了三重归一化:没有 answerer 兜底 'unavailable',返回值不在词表里归一化成 'unavailable',抛错同样归一化成 'unavailable',signal abort 则 resolve 成 'cancelled'

会话策略只有 'ask' | 'never' 两档,服务 Config 的 schema 默认是 'ask'never 的判定被放在 waterfall 派发之前,源码注释的说法是:一个后来用 prepend 注册的监听器绕不过它。

还有一处容易被忽略:ApprovalRequest 故意不携带工具参数。文档给的理由是,answerer 通过 callId 把提示挂到那次已经流式呈现过的 tool call 上,而不是再渲染一份可能漂移的副本。审批的审计事件是成对的 approval/askedapproval/decided,两者都是 log-only,不进模型 transcript。

一个必须照实说的现状:我们没能在这个快照的仓库里找到一个「默认就会对普通工具调用发起 ask」的独立策略插件。非测试源码里监听 'tools/pre-execute' 的只有注册表自身、invariant.ts、生成的 scope 表、tool-cordis 的 API 目录数据、两个 hooks 桥和 tool-jobs(后者那个监听器只是记录输出上限后直接 next())。ask 的两个已知来源,是 Claude Code / Codex hook 桥的 PreToolUse 映射,以及 bash、fs 的沙箱升级路径。

结果落盘:哪一步之后就改不动了

工具 execute() 返回之后,还有一长串。这里有三个点值得记住。

第一,tools/post-execute 能做的事情有边界。PostToolDecision 的类型只有 acceptblock 两支,且 accept 要么带 content、要么带 value,不能两个一起改。文档在这里写了一句很硬的话:内容替换是呈现策略,不是保密策略——要藏住一个值,必须 block 或者把 value 换掉。只把 content 改掉,值还在。

第二,normalized 这一步是注册表的外层归一化:管线里抛出的异常、结果快照里抛出的异常,都变成 isError 而不是把这一轮对话打断;未知工具走 ToolNotFoundError,映射到 UNKNOWN_TOOL,文档原话是这样这次调用会失败但不会结束这个 turn。

第三,tools/result 是 emit 而不是 waterfall,图里标它是 frozen authoritative outcome。到这一步为止结果已经定型,后面的 additionalContexts 是在记录 tool results 之后才作为 user/message 注入的,属于「另外补一条上下文」,不是改这次结果。

参数那一侧也有对应的冻结。docs/cookbook/adding-a-tool.md 写明注册表会把 arguments 一次性物化成 detached 的无损 JSON,并在 policy 开始之前就冻结,同时分配一个不透明的 exec.token;只有 around-dispatch 的 wrapper 能拿到可变视图,而且它只能替换、不能移除 exec.signalpackages/core/tools/README.md 的 Known Limitations 里也确认了这件事的另一半:tools/pre-execute 故意不能重写 exec.arguments,重写方案目前还只是一份 proposed 状态的 Agent Note。理由在类型注释里:参数已经被记录、也已经呈现给用户了。

顺着这条线还有一个「写了不等于会执行」的例子:同一份 Known Limitations 写着,definition 上的 timeoutMs声明性的,注册表从不强制 deadline,真要强制得靠 @deepseek-ai/dsh-tool-call-timeout-policy 这个 wrapper。看到 schema 里有 timeoutMs 就以为超时会自动生效,是会踩空的。

一处口径差异:bash 的参数表

写这类文章时最好顺手核一遍文档与源码。docs/tool-catalog.mdbash 的 schema 列了 5 个参数:commanddescriptiontimeoutMsworkdirrun_in_background。而 packages/shell/tool-bash/src/index.ts 里另有 sandbox_permissionsjustification 两个参数,它们是条件展开的:只有 escalationModes.length > 0 时才加入,而 escalationModes 仅在 ctx.shell.sandboxMode !== undefined 时取 ESCALATION_TARGETS。描述文本同理,目录里那段描述恰好等于源码 bashDescription()escalationModes.length === 0 时返回的 base,不含后面那段升级指引。

两处位置就是上面这两个文件。目录自己在开头声明过,它是把每个工具包以其 DEFAULT config 启动、再读 ctx.tools.schemas() 生成的。我们只记录这一差异,不推断哪一份「才对」。要看你自己部署下模型实际会看到几个参数,去看你的组合里 ctx.shell.sandboxMode 是不是有值。

Code Mode 的 sub-calls 走的是同一条管线

run_code 这个工具只有两个必填参数:code(一个 async 函数的函数体,仅限可擦除语法,顶层 awaitreturn 可用)和 description,来源标注是 packages/core/tools/src/code-mode.ts。它的特别之处在管线这一侧:文档写明保留传输 run_code 和它序列化出来的 sub-calls 都会被送进同一条管线,sub-calls 携带 parent token、记 tool/code-dispatch 事件、拒绝以 binding rejection 的形式返回,并且省略 additionalContexts,以保持 call 与 result 相邻。

所以「用 Code Mode 绕开权限」这个直觉是不成立的——至少按文档的口径,它绕不开 pre-execute 与 guard。

最后两句实在话

这条管线里 toolBody 那一格,跑的是本机的真实副作用:bash 会在你的机器上起进程,tool-fs 会写文件,沙箱模式只有 read-onlyworkspace-writedanger-full-access 三档,而且 JSDoc 明说网络与进程可见性不在这套词表里。管线设计得细,不等于装上就安全,这两件事得分开看。

想自己核一遍的话,起点就三个文件:docs/tool-execution-pipeline.md(62 行,看图与边)、docs/subsystems/tools.md(720 行,看六个 tools/* 扩展点与三个决策类型)、packages/core/tools/src/index.ts(1946 行,看 prepareExecution()serviceAsk 的实际顺序)。图和代码在这一点上是能对上的,但对上的是顺序,不是运行时行为——后者我们没有验证过。

延伸阅读


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-16,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库建立于 2026-08-13,README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。 安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。