跳到主要内容
路径文档

工具执行

一句话版:工具 = 插件注册的 parameters + output + execute。每次调用走一条可扩展流水线(tools/pre-execute 门禁 → 守卫 → tools/execute 环绕 → tools/post-executefinalizeContenttools/result 通知),并用 mode/presentAs 决定以 native/code/both 呈现给模型。注册表在 core/tools,ctx.tools

这是 DSH"能力是插件做出来的"的最直接体现。理解了工具,你就理解了模型"能做什么"是怎么来的。

一、工具是什么

工具 = schema(模型能看到什么) + 执行器(真正干什么) + 输出契约(返回什么)

工具插件通过 ctx.tools 把自己的 schema 交给系统提示词装配(ctx.systemPrompt.tools() 自动喂),执行器在模型调用时运行。一次请求里,模型"看到"的是工具的 parameters;看不到 execute(执行函数不进模型上下文)。

最小工具(标准写法 defineTool)

import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

declare const ctx: Context

ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.',
parameters: {
path: { type: 'string', required: true, description: 'Absolute file path' },
offset: { type: 'number' },
limit: { type: 'number' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
// args 是被类型推断过的: { path: string; offset?: number; limit?: number }
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
},
}))

defineTool 的三个好处:

  1. 类型化:parameters 编译成 TypeScript 类型,executeargs 被精确推断
  2. 自动校验:参数在执行前校验,缺必填/错类型/非法 enum → ToolArgsError(INVALID_ARGS)走正常错误结果路径
  3. 输出推断:从 output.schema 推断返回类型与纯渲染

更底层的 ctx.tools.register() 也行,但字段相同(parameters + output:{schema,render} + execute),校验要你自己负责。首选 defineTool

二、三种呈现方式:mode / presentAs

工具的schema不变,但如何呈现给模型分三种:

tools:
mode: native # native(默认)| code | both
mode模型看到说明
native函数定义标准 function calling
code保留的 run_code 传输 + 生成的 tools:sdk让模型用"代码 + 类型化 SDK"调用(见 Code Mode)
both两种形式都提供
  • config 的 modeagent 没声明自己偏好时的默认;单个 agent 用 ctx.tools.presentAs(mode) 遮蔽默认(agent.ctx 里调;普通上下文会抛错)。
  • tools/execute 呈现由 core/agent-tool-presentation 决定(native/code/both;见 Agent 预设)。
  • 非 native 模式需要一个 ctx.codeRuntime,其 language 要有注册的 SDK renderer(TypeScript 走 dsh-code-runtime-worker-thread;Python 内建)。

run_code 是保留名

run_code 这个传输名无论何配置模式都被无条件保留:不可注册/遮蔽/限制/移除,因为任何 agent 都可能自己切到 code 模式。想注册叫 run_code 的工具?不可能。

三、执行流水线(每次调用走五段)

源码原话:

registry 执行每次调用,经过 tools/pre-execute(可扩展的 allow/deny 门禁)→ 单调守卫tools/execute(around-dispatch 包装:超时/重试/指标)→ tools/post-execute(检查/替换结果、附加上下文)→ 定义自有的 finalizeContent 边界 → 只观察的 tools/result 通知。

每一段是插件扩展点:

类型你能做什么
tools/pre-execute可重排 waterfallallow / deny / ask(返回 allow、deny 或 ask 决策)
ctx.tools.guard()同步守卫返回 reason 即拒绝;单调:后续 waterfall 不能把拒绝翻回许可
tools/executearound 包装加超时/重试/指标;只能替换 signal,不能碰别的
tools/post-executewaterfall替换 content / 替换 value / block / 附加 ordered contexts
finalizeContent定义自有每个规范化结果恰好跑一次(含跳过 post-policy 的失败),只能替换 content,必须同步、total
tools/result观察通知只看最终结果,不能改

关键:tools/result进程内实时事件;紧接着 agent-loop 追加的 tool/result(单数)才是持久化会话事件。别混(见 事件系统)。

四、守卫族:timeout-policy 与 repeat-tool-reminder

packages/guard 组是两个行为守卫插件——盯着 agent 循环里"无产出"的模式、执行每调用预算。它们都是核心服务与扩展点的自包含消费者,不是可替换的 capability。两个都默认挂载于 base bundle

角色挂点
dsh-tool-call-timeout-policy(插件 id 保持 timeout-policy)按工具声明布置每调用截止时间,作为部署策略注册一个 tools/execute 监听器
dsh-repeat-tool-reminder对重复工具调用注入劝告性提醒监听 tool 与 agent 事件

别和流水线里的 ctx.tools.guard() 同步守卫混淆(见上文三)——那是注册表内联的 allow/deny 机制;这里两个是 packages/guard 组的独立行为插件。

timeout-policy:把 timeoutMs 变成协作式截止

@deepseek-ai/dsh-tool-call-timeout-policy零配置tools/execute around 监听器:预算来自工具自己的声明(ToolDefinition.timeoutMs,由拥有该工具的工具插件设置),所以它只负责执行、不负责定预算——工具名拼错这种事根本不可能发生。它是 tools/execute 环绕包装的参考实现

对一个声明了 timeoutMs 的工具:

  1. 从注册表读预算(ctx.tools.get(exec.name)?.timeoutMs),用 deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT') 布置一个信号——把调用方自己的 abort 和这个插件的定时器融合成一个(@deepseek-ai/dsh-timeout)。
  2. 把这个派生信号换到 exec 上交给下游分发,之后再恢复调用方原始信号(cordis 的 next() 忽略传参,所以包装器是原地改共享的 exec;恢复是为了让 tools/post-execute 看到调用方的信号)。
  3. 分发之后,若 timeoutOf(d.signal, 'TOOL_TIMEOUT') 命中——也就是这个插件自己的定时器触发了——就把结果替换成结构化 TOOL_TIMEOUT:{ isError:true, error:{ message, info:{ name:'ToolTimeoutError', code:'TOOL_TIMEOUT' } }, content:'Error: tool call timed out after <ms>ms' }

没声明预算的工具原样委托(不布置截止时间)

协作式,不是硬杀:派生信号只是通知;真正的终止权在工具和它转发 exec.signal 的那个 capability 手里(dsh-timeout 库本身不拥有任何 kill)。所以"声明 timeoutMs"意味着承诺与 exec.signal 协作——忽略信号的工具到点也不会停,只有会转发信号的工具才该声明它(web_fetch/web_search 是参考)。TOOL_TIMEOUT 不需要额外会话事件即可重建:它就是最终面向模型的 tool/result,循环已经记了日志。

关于替换为什么要看信号(timeoutOf)而不是看分发结果的形状:tools/execute 的底层 next() 是注册表的"分发 + 规范化"thunk,所以超时信号先到某个会抛上游 abort 错误的 provider 时,分发会先把它变成普通错误结果,这个包装器再把那个普通结果换成 TOOL_TIMEOUT

repeat-tool-reminder:劝告性防复读

@deepseek-ai/dsh-repeat-tool-reminder 是一个劝告性的循环破局者,不是面向模型的工具:它从不进工具清单、从不否决或改写调用,只做一件事——盯着每个 agent 的工具调用流,数"连续调用同一个工具 + 规范化后完全相同的参数"的连续次数,在配置的连续长度上注入逐级升级的提醒,让模型别再重复、重读上次结果、要么换方法要么收尾。决策(换个重试/多收集证据/结束)完全在模型手里:合法地重复调用不会被延迟、也不会被拦。

config:
thresholds: [3, 5, 8] # 默认;触发提醒的连续次数
include: [] # 要追踪的工具名模式;空 = 全部工具
exclude: [todo_write] # 对链透明的工具名模式
argumentsPreviewChars: 500 # 默认;详细提醒里引用的参数上限
  • 链的 key 是 (工具名, 规范化参数):规范化 = 深 key 排序 + JSON.stringify,所以只是属性顺序不同的参数对象算"相同"。与上一个被追踪调用相同 → 该 agent 的连续计数 +1;不同 → 重置为 1。
  • 未被追踪的调用对链透明:被 include/exclude 排除的调用既不递增也不重置,所以 grep X → todo_write → grep Xtodo_write 被排除时仍算两次连续的 grep X——排除的用处正是:混进循环里的记账工具不能"洗白"循环。
  • 被拒调用也算:检测挂在 tools/post-execute 上,这个事件对 tools/pre-execute 拒绝的调用也会跑——模型反复捶一个被拒调用,恰恰是该破的循环。
  • 按 agent 记:注册表是 context 级、子 agent 会交错经过同一条瀑布,所以用 WeakMap<Agent, Chain> 按活 agent 对象记;一个 agent 的复读不会触发另一个的提醒。用户提示(agent/pre-step)重置提交者 agent 的链。
  • 只存内存:从持久化恢复的会话从空链开始——它是启发式 nudge,不是被记日志的不变量。

提醒的送达:提醒走 post-execute 决策的 additionalContexts(来源 {kind:'plugin', plugin:'repeat-tool-reminder'}),从不替换 content——tool/result 事件保持工具自己的输出供审计。循环缓冲这段上下文,在 step 的工具结果之后把它作为注入的 user/message 追加,会话渲染成一条普通合成用户消息。所以提醒是模型可见、有来源标注、且能从会话日志重建的,无需新会话事件。

第一个阈值发短提醒,后续每个阈值发详细形式(点名工具、连续次数、规范化参数——按 argumentsPreviewChars 头截断并带省略计数标记,所以循环的 write/edit 载荷不会无界地带进下一次请求;链 key 永远比完整规范化串,上限只约束提醒、不约束检测)。

效应说明
token阈值前零 token;每条提醒是保留历史,argumentsPreviewChars 约束数据相关文本
KV cacheappend-only;新可见内容跟在可复用请求前缀之后,不失效已有条目

五、输出契约与内容/呈现分离

工具返回的"东西"分两部分:

内容(content)值(value)
是什么面向 UI/模型的渲染规范的 JSON 值
谁能拿到模型上下文程序化消费者
转换render(args, value)执行器返回

工具体只能返回 output.schema 声明的那个规范 JSON 值;注册表会校验并冻结它,再 materialize 呈现字段。结果形状:

// 成功
{ isError: false, value: JsonValue, content, meta?, additionalContexts? }
// 失败
{ isError: true, error: { message, info? }, content, meta?, additionalContexts? } // 没有 value

ToolFailure.infoHarnessError 的内部 { name, code }

additionalContexts:执行期把上下文还给循环

ToolRunContext.deferContext(context) 把一段上下文延迟到该工具最终结果到达 agent-loop 才注入:即使工具后来 throw 或取消也保留,绝不当场注入。additionalContexts 保留每个被延迟或 post-execute 识别的 UserMessage,供循环的 post-result FIFO。

这就是 Agent 主循环 里"执行期把上下文还给循环"的通道。

六、取消语义:协作且安静

取消是**协作式、安静(quiescent)**的,不是强杀:

  • 每个工具体拿到必需的只读 exec.signal(AbortSignal);每个异步工具必须观察或转发它,并只在自己拥有的工作停止后 settle
  • 只在 tools/execute 包装层能临时替换 signal;注册表会在 body 前重新 fuse(合流)原调用方信号,保证替换不会丢掉调用方取消
  • 取消时机决定结果:
    • body 调用前取消 → ABORTED_BEFORE_DISPATCH
    • body 调用后取消 → 只能把成功结果替换成 ABORTED
    • 更具体的:denial / wrapper 失败 / tool 失败 / post-policy 失败 / 超时 → TOOL_TIMEOUT 保持更具体
  • 一个已预中止的调用:物化并冻结参数,跳过所有 policy/dispatch 阶段,发布一个结果
  • 注册表没有硬杀能力:同进程 promise 一旦开始,不会让它竞态溜走

七、类型化参数 schema(defineTool 的 DSL)

defineTool 的统一 schema DSL 支持:

string / number / integer / boolean / null / array / object / json(作者专用)/ oneOf(精确一)
  • 显式 DSL 对象都声明 additionalProperties: true|false;隐式参数根 + 裸 JSON Schema 保持标准 open
  • enum/const 标量类型正确
  • 默认值不自动应用(类型对,默认值要你自己处理)
  • 校验用显式工作栈,对很深 schema 运行时内存有界(不爆调用栈);InferValue 保留精确类型到 16 层容器,之后退回 JsonValue
  • register 的工具自己负责输入校验,但仍声明并受 registry 的 output 校验

八、作用域与可见性(scope)

工具的可见性由作用域链决定(和 插件解剖 的 scope 一致):

注册位置可见性
普通插件上下文全局工具
agent.ctx仅该 agent,遮蔽同名全局工具
restrict(filter)(限 agent.ctx)继承工具施加 allow/deny 掩码(全局层 + 祖先作用域)

几个关键性质:

  • restrict 对作用域自己的注册豁免、之后合并:这正是"委派子 agent 时,它的回报/结构化输出工具在窄 filter 下仍存活"的机制
  • 多个掩码取交集;祖先掩码穿透到每个嵌套 scope
  • deny 掩码放行后出现的未列名继承工具;allow 掩码排除后出现的名字
  • 这是实时可见性组合,不是权限边界(scope security 在源码里明确是 non-goal;真正的限制见 沙箱)

取某个 scope 看到的工具:

ctx.tools.get(name, scope) // 单个定义(遮蔽 + restricted 都应用)
ctx.tools.schemas(scope) // 可见工具的 schema(无 execute)

九、并行与独占(execution mode)

agent-loop 把并发工具调用分成两类:

  • exclusive(默认):排队屏障,串行
  • parallel:schemaisConcurrencySafe(args) 返回恰好 true 才放行并行;未知/隐藏/未声明/非法/抛错 的分类一律 exclusive
ctx.tools.register(defineTool({
// ...
isConcurrencySafe(args) {
// 只有能安全并行的才返回 true;opt-in 工具不得改动父持有状态
return args.kind === 'readOnly'
},
}))
  • agent-loop 把连续 parallel 调用归一进有界滚动池,exclusive 调用作为顺序屏障
  • 只有 dispatch/body 重叠;policy、持久化结果、result context 保留模型顺序
  • maxParallelToolCalls(默认 10)封池上限

十、Code Mode(高级但强大)

code/both 模式让模型用程序 + 类型化 SDK调用工具,而不是逐条 function call。SDK 段(tools:sdk,order 150)在每次装配时确定性地重新生成(lexicographic 工具序,字节级一致 → 前缀缓存友好)。

关键点:

  • run_code 传输 + 一个在加载 runtime 语言里生成的确定性 SDK
  • 只有程序的外层 logs 与 return value 回到模型上下文:子调用细节不污染
  • 绑定调用入完整工具流水线,concurrency-safe 可重叠到 maxParallelSubCalls(默认 10;1 = 串行),exclusive 子调用作为屏障
  • 每个绑定调用在流水线入口记 tool/code-dispatch-start,settle 记一波 tool/code-dispatch(带完整模型面 content/isError)
  • 拒绝/失败 → 程序可见的 ToolCallError(toolName, message);普通副效应不回滚;运行 settle 会 abort 并 drain 未完成绑定
  • 运行失败 → CodeRunFailedError(code:'CODE_RUN_FAILED'),转结构化 isError 让模型自纠
  • run_code 返回 { logs: string[], result?: JsonValue };worker 的 maxOutputBytes 默认 64 MiB;带图像的子工具结果在运行结束后附加(rc.7)

Code Mode 把"一次复杂操作"折叠成一段程序,模型写代码、逐个绑定调用,而不是频繁来回 function call。这是 DSH 偏"程序化调用"的设计。想试:pnpm run demo:code-mode

代码模式运行时:code-runtime 服务定义 + worker 后端

packages/code-runtime 组是代码执行的 capability seam,分两层:

角色ctx key
dsh-code-runtime服务定义 + 共享词汇,只说"做什么"不说"怎么做"ctx.codeRuntime
dsh-code-runtime-worker-threadworker 线程后端,注册该服务(注册 ctx.codeRuntime)

后端默认挂载于 headless 与 web 两种模式 bundle(id code-runtime),base bundle 本身不挂载。

**服务契约(ctx.codeRuntime)**只定义三件事:

成员语义
run(request)对请求的 bindings 执行一个程序;每个程序结果都以 error 字段 resolve——解析/转换失败、抛异常、非法完成、输出超限、预算耗尽、abort、底层死亡(CodeRunFailure 的正交 kind 分类);只有调用方违反服务定义契约(如 dispose 后再提交)才 reject。程序以 async 函数体运行:顶层 await/return 可用,无损 JSON 完成变 result.value
language只读描述:run 期望的源语言。'typescript''python' 是知名值(只有 'typescript' 有已发布后端)。信息性、非门禁。
isolation只读描述:执行基板('worker-thread' / 'process' / 'container')。是给部署/诊断的标签,不是安全声明

每个实现必须遵守:绑定调用桥接无损 JSON参数与结果、seam 层无字节上限;程序按敌对 peer 对待(任意绑定名是 own property、畸形流量不崩 host);两次运行之间不留状态;dispose 终止在飞运行并等待其退出

worker 后端(WorkerThreadCodeRuntime)把每个程序跑在一个全新node:worker_threads.Worker:TypeScript 进、host 侧类型剥离、绑定经消息端口桥接、{ value, logs, error? } 出。

  • 一次一个全新 worker,不池化:程序的世界随 worker 一起死——没有跨运行状态可记、状态泄漏不可表达、运行只靠会话日志就能重建。
  • host 侧类型剥离:程序被包进 async 函数壳、用 node:modulestripTypeScriptTypes 剥离(仅可擦除语法——enum/namespace 直接判为程序 exception、不 spawn worker)、再按字节位置切回,作为 AsyncFunction 体执行,所以顶层 await/return 可用。
  • 端口假设敌对 peer:模型代码能拿到 parentPort 并伪造流量,所以每条入站消息先形状校验再重建(null/primitive/垃圾类型/畸形载荷直接丢、伪造多余字段不带走)、host 每个 call id 至多应答一次、绑定名只按 own property 解析(伪造的 constructor 走不了原型链)、丢弃结算后的回复、每个绑定解析与完成都做无损 JSON 校验。
  • 双独立预算(因为 peer 是敌对的):computeMs 计 worker 的实测忙碌时间(轮询 worker.performance.eventLoopUtilization()——热循环藏不进挂起的诱饵分发,等慢工具的等待不计费);maxWallMs 兜底忙碌时间看不见的情况(等一个没人 resolve 的 promise)。两者都汇进 worker.terminate()(能终止热的同步循环);堆溢出表现为 worker 的 OOM 退出。
  • 日志即时流进一个外层账本:console/stdout/stderr 文本按发出顺序过端口,所以超时/被杀的程序仍能看到它打了什么。maxOutputBytes 计 JSON 序列化字节,完成值与异常诊断在贴出前先对剩余预算预检——抛出的百万字节栈在 worker 边界变成固定的 output-limit 诊断。
  • 空环境:workerenv:{} + execArgv:[]——没有环境凭据、没有继承的 loader flag。
  • dispose 到静默:teardown 把在飞运行标记为 abort等待每个 worker 退出后才 resolve。
配置默认含义
computeMs60000忙碌时间预算(测 event-loop 活跃时间)
maxWallMs600000墙钟上限,对任何事都不暂停
maxOutputBytes67108864序列化外层输出的合计上限(64 MiB)
maxOldGenerationSizeMb512worker 堆上限(resourceLimits)

containment,不是安全边界:worker 后端故意按"等价 bash"的信任姿态设计——bash 没有的隔离它才给(独立 isolate、空环境、堆上限、硬终止)。只有外层 run_code 结果进入模型上下文并走普通 spill 策略;绑定流量与中间值留在执行本地。

十一、工具自有 UI 渲染(presentCall/presentResult)

工具可以返回纯渲染意图,让 UI 不需要为特定工具名特判。两种卡片:

presentCall() 决定 UI 怎么渲染"模型要调这个工具"
presentResult() 决定 UI 怎么渲染"这个工具的结果"

卡片词汇表:generic / terminal / diff / search(grep/glob 的完成发现搜索,带 truncated/total)/ read(行号 + 可选高亮的文件读)/ web(search/fetch)。返回 undefined = 用通用兜底。

  • renderer 只依赖它的参数 + 持久化结果(UI 在实时流与日志回放都调用它)
  • output.presentationMeta(args, value) 派生 JSON 元数据,随 tool/result 持久化,回放时回到 presentResult
  • dsh-tool-bash / dsh-tool-fs 是参考实现

意义:让"文件读取显示成代码视图、web 搜索显示成来源网格、diff 显示成 diff"成为工具的职责,而不是每个 UI 去硬编码工具名。

十二、MCP 工具并入同一体系

dsh-mcp-client(见 MCP 集成)把外部 MCP 服务器的工具以 mcp__<server>__<raw> 注册进 ctx.tools。它们走同一条流水线:pre-execute 门禁、超时、取消、渲染都适用。规则:一个插件一个 server,discover 工具后 call ctx.tools.register()

验证

# 看当前 profile 注册了哪些工具
dsh web --dump-config | grep -A2 "tool-"

# 会话日志里看一次工具调用的流水线(单数 tool/ 前缀,默认 zstd 压缩、两级目录)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -E '"tool/call"|"tool/result"' | tail -6

下一步