工具执行
一句话版:工具 = 插件注册的
parameters+output+execute。每次调用走一条可扩展流水线(tools/pre-execute门禁 → 守卫 →tools/execute环绕 →tools/post-execute→finalizeContent→tools/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 的三个好处:
- 类型化:
parameters编译成 TypeScript 类型,execute的args被精确推断 - 自动校验:参数在执行前校验,缺必填/错类型/非法 enum →
ToolArgsError(INVALID_ARGS)走正常错误结果路径 - 输出推断:从
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 的
mode是 agent 没声明自己偏好时的默认;单个 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 | 可重排 waterfall | allow / deny / ask(返回 allow、deny 或 ask 决策) |
ctx.tools.guard() | 同步守卫 | 返回 reason 即拒绝;单调:后续 waterfall 不能把拒绝翻回许可 |
tools/execute | around 包装 | 加超时/重试/指标;只能替换 signal,不能碰别的 |
tools/post-execute | waterfall | 替换 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 的工具:
- 从注册表读预算(
ctx.tools.get(exec.name)?.timeoutMs),用deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')布置一个信号——把调用方自己的 abort 和这个插件的定时器融合成一个(@deepseek-ai/dsh-timeout)。 - 把这个派生信号换到
exec上交给下游分发,之后再恢复调用方原始信号(cordis 的next()忽略传参,所以包装器是原地改共享的exec;恢复是为了让tools/post-execute看到调用方的信号)。 - 分发之后,若
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 X在todo_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 cache | append-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.info 带 HarnessError 的内部 { 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保持更具体
- body 调用前取消 →
- 一个已预中止的调用:物化并冻结参数,跳过所有 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:schema的isConcurrencySafe(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-thread | worker 线程后端,注册该服务 | (注册 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:module的stripTypeScriptTypes剥离(仅可擦除语法——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诊断。 - 空环境:worker 拿
env:{}+execArgv:[]——没有环境凭据、没有继承的 loader flag。 - dispose 到静默:teardown 把在飞运行标记为
abort并等待每个 worker 退出后才 resolve。
| 配置 | 默认 | 含义 |
|---|---|---|
computeMs | 60000 | 忙碌时间预算(测 event-loop 活跃时间) |
maxWallMs | 600000 | 墙钟上限,对任何事都不暂停 |
maxOutputBytes | 67108864 | 序列化外层输出的合计上限(64 MiB) |
maxOldGenerationSizeMb | 512 | worker 堆上限(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持久化,回放时回到presentResultdsh-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