读完这篇你能:知道让模型”跑一段代码”这件事在 DSH 里是怎么接的;理解为什么失败要被分成八个正交的类别、又为什么它是结果里的字段而不是异常;以及子进程那条缝上哪些环境变量归谁管。
前置:32-系统提示词的配置。约 30 分钟。
示例代码:dsh-code-lab
DSH 版本:0.2.0-rc.2
32-系统提示词的配置讲的是”给模型立规矩”,这一篇讲”给它动手的地方”。
模型写一段代码、跑一下、看结果——听起来简单,但接的时候有几个决定要提前定清楚:失败怎么报、它能看见什么、跑在哪儿。这几条定错了,模型就没法从错误里恢复。
这条能力在 DSH 里叫 PTC(程序化工具调用):宿主把一组函数作为绑定交给程序,程序用一个独立的执行基底跑完一段,回来交日志与完成值。PTC 预设下交进去的那份绑定,就是模型的工具表——模型不是一个个调工具,而是写一段程序去调它们。
目标
- 分清 PTC 里”请求”与”执行规格”的边界。
- 知道八个失败分类为什么必须分开。
- 理解绑定命名空间的作用与风险。
- 知道子进程那条缝上环境变量的归属规则。
DSH 设计
跑代码这件事,在 DSH 里不是主循环的固定零件,而是一条可以整条换掉的能力缝。所谓「能力缝」(seam),是 DSH 组织每项能力的三件套:一份接口定义、一个实现方、若干使用方;换掉实现方,使用方一行不用改。跑代码这条缝叫 PTC(程序化工具调用):宿主把一批函数交给一小段程序,程序在独立的执行环境里跑完,交回输出与结果。子系统文档把它的身份写死了:可选能力,不属于驱动对话的主循环。所以部署可以不装它,装了也可以换——程序跑在 worker 线程、独立进程还是容器里,由实现方决定,接口只如实报告、不作安全承诺。「可换」甚至延伸到了命名上。程序里看到的全局变量名必须落在各种语言都合法的标识符子集里,$tools 这种只在 JavaScript 里合法的拼写被设计明确拒绝。理由很直接:同一份名字表要对每个后端都成立,换实现方才不会废掉已经写好的程序。
接着的问题是谁来定默认值,答案是实现方。调用方提交的请求里,工作目录、时间预算都可以留空。实现方先用一步 resolve() 校验支持的选择、套上出厂默认值与上限,把请求「落定」成一份不再有任何待定项的「执行规格」;run() 只执行规格,绝不替谁补一个缺失的选择。两步合起来看:
// 调用方提交的请求:cwd 与 timeoutMs 都留空。
const request = { program: 'return 1 + 1', bindings: [] }
// resolve 之后落成规格:cwd 已是绝对路径,timeoutMs 已套上默认值、截到上限。
const spec = ctx.ptcRuntime.resolve(request)
同一条原则在更底层的子进程缝上推得更彻底:那条缝不设任何默认值。每条输入输出流怎么接、上限多少、在哪个目录跑,全部要在请求里逐项写明,由调用方自己的配置决定,而不是被某个隐藏的服务默认值悄悄决定。
再接着是失败怎么报。执行接口的 run() 永远不抛异常:程序跑挂了,错误放在结果的一个字段上,调用方读字段,而不是写 try/catch。失败类别有八种,设计要求彼此正交,也就是互不重叠:预算耗尽不是异常,被取消不是超时,执行环境整个崩掉(比如内存耗尽)又不是其中任何一个。分这么细不是洁癖。每一类指向一种不同的补救动作;压成一句「运行失败」,能用来自救的信息就全丢了。
最后看底座。子进程缝的使用方不是模型,也不是普通插件(挂载进系统的功能代码),而是其他能力缝:跑命令的执行器、语言服务器的协议管道、长驻终端、自动化子助手,都架在它之上起进程。它还替整个系统看住一类环境变量:凡是以 DSH_ 开头的变量都归运行时自己所有。起子进程之前,先把环境里已有的同名变量丢掉,只放行调用方显式写出的条目。想删掉一个从父进程继承来的普通变量,也有正式的「留空即删除」写法,而不是塞一个空字符串。
各次运行彼此隔离、失败分类各指一条自救路、默认值只住在实现方一侧——这三件事合起来,就是「执行环境可以换」在接口上的样子。
一条可选的缝
PTC 执行是一个标准的能力 seam:Service Definition 用宿主提供的异步绑定,运行一段模型编写的程序,报告它的输出、完成值与适用的沙箱事实。
有一句话要留意:PTC 执行是可选的,不属于 agent loop 主干。 这就是为什么它的词汇定义在子系统页而不是 core.md 里——主干上的东西不能是可选的。
一次运行交给服务的是一份请求:
interface PtcRunRequest {
program: string
bindings: PtcBindingNamespace[]
cwd?: string
timeoutMs?: number | null
sandboxPolicy?: SandboxExecutionPolicy
signal?: AbortSignal
}
请求里的可选项在真正执行前都会被定下来,中间隔着一个 resolve():
resolve(request)校验支持的选择并套上部署默认值与上限,返回一份目录与截止时间都明确的PtcRunSpec;run(spec)执行它。 也就是说run永远不会去补一个缺失的目录或截止时间选择。
interface PtcRunSpec extends PtcRunRequest {
cwd: string
timeoutMs: number | null
}
所以那几个可选字段各自的含义是:
cwd:程序在挂载的文件系统与子进程执行世界里的工作目录。省略时由提供方从沙箱策略的工作区根解析,resolve之后必须是绝对路径。timeoutMs:经过时间预算(含嵌套的工具与审批等待)。省略用提供方默认值,给数值会被截到部署上限,null表示明确要求不设截止。sandboxPolicy:这次执行的授权。不做约束的提供方会拒绝显式传入的策略。
服务上还有一个 timeout 只读属性:支持逐次覆盖的提供方在这里报告默认值与上限(dsh-ptc-runtime-node 出厂是 120 秒默认、600 秒上限),不支持逐次覆盖的返回 undefined——消费者在这种提供方上就不该露出超时旋钮。
program 会作为 async 函数的函数体运行,所以顶层 await 和 return 都能用,而完成值会成为结果的 value。
把请求、规格与执行放在一起看:
绑定:宿主函数作为程序全局变量
bindings 是把宿主能力交给程序的方式:
interface PtcBindingNamespace {
global: string
functions: Record<string, PtcBindingFunction>
errorClass?: PtcBindingErrorClass
}
type PtcBindingFunction = (args: unknown) => Promise<PtcJsonValue>
一个命名空间一个全局对象。 也就是说你声明 global: 'lab'、里面有个 upper 函数,程序里就能直接写 lab.upper({ ... })。
这里有几个判断要做对:
给多宽? demo 里只给了两个函数。把整个 ctx 递进去的那种做法很方便,但它等于把插件的全部能力(包括注册、销毁、访问服务)暴露给一段模型写的代码。PTC 预设自己交进去的命名空间叫 tools,装的就是模型的工具表——一个命名空间的宽窄,就是一段程序能碰到的边界。
名字要可移植。 global 必须是 [A-Za-z_][A-Za-z0-9_]*,不能是任一目标语言的保留字;后端自己占用的名字(console、__dsh_main__、__builtins__、__name__、__debug__)也被统一拒绝。这条故意不按后端分家:一份在 TypeScript 后端能用的命名空间表,在 Python 后端上也必须能用。
参数与返回值都是无损 JSON。 绑定函数只收一个参数:程序那边写 lab.upper({ text: 'x' }),宿主这边收到的就是那个对象。传一个不能无损序列化的值会被拒绝,而不是把这次运行弄坏。
异步。 绑定函数的签名是返回 Promise 的,所以程序里调它们得 await。
还有一个容易漏的约定,写在 signal 的注释里:
in-flight 的绑定调用是调用方自己的事——runtime 只是不再问了。
也就是中止时,runtime 会硬停下那段程序(即使在循环中间),但已经在执行中的绑定调用不会被它收回。那些调用的结算归调用方。如果你写的绑定是”发一个网络请求”,那它得自己处理中止。
失败是字段,不是异常
这套接口最反直觉的一条在这里。
run() 不会返回被拒绝的 Promise。它总是正常兑现,把错误放在结果里的一个字段上:
interface PtcRunResult {
sandbox?: PtcRunSandbox
value?: PtcJsonValue
logs: string[]
error?: PtcRunFailure
}
理由和 shell 执行器那条约定一致:报告程序失败是调用方的职责,不走异常路径。
这两条路带来完全不同的写法。用异常的话,调用方要 try/catch,而 catch 到的东西和”程序自己抛的异常”混在一起——你分不清”这段代码写错了”和”这段代码里真的 throw 了”。用字段的话,这两件事天然分得开(后者是 kind: 'exception')。
sandbox 还要单独说一句:它是一份独立的事实——这次执行用了哪种文件效果模式、有没有观察到拒绝、约束后端报告的完整性如何,各说各话。程序成功并不证明每一项请求限制都被强制执行了。
八个失败分类
失败类型是正交的结果、独立报告的:
interface PtcRunFailure {
kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit' | 'protocol' | 'sandbox-unavailable'
message: string
}
子系统文档对”正交”的解释写得很直白:
预算耗尽不是异常,中止不是超时,基底崩溃(如 OOM)也不是二者中的任何一个。
逐个看这八个,能看出它们对应的是八种不同的自救方式:
| kind | 什么情况 | 模型该做什么 |
|---|---|---|
exception |
程序抛出,或解析/转换失败 | 改代码 |
timeout |
实现自己的预算到期 | 缩短任务、别写死循环 |
abort |
请求的 signal 触发了 |
这是被叫停的,不是它的错 |
worker-exit |
执行基底死了(如 OOM) | 少占内存,或任务本身太大 |
invalid-output |
完成值不是无损 JSON | 返回值改成可序列化的 |
output-limit |
序列化的日志/值/诊断超过上限 | 少打印点东西 |
protocol |
程序发出的控制流量非法或超出预算 | 别绕过顶层 return 与 console 那条通道 |
sandbox-unavailable |
要求的约束没能建立起来 | 不是代码的问题,是环境的问题 |
如果这八种被压成一个”运行失败了”,模型能做的只有瞎猜。分开了,它至少知道是改代码、还是改任务规模、还是别打印那么多。message 的定位也写在注释里:适合喂回给模型让它自我修正。
八类成因都从同一条出口出来:
输出与上限
关于捕获的输出,有几条实现层面的约定:
- 日志是纯字符串。
logs是一个字符串数组,每个来源通道保留自身的发出顺序;但通道元数据不属于这条缝,所以相互独立的通道之间怎么交错由后端决定——别依赖两个通道之间的相对顺序。 - 上限作用在”可变载荷”上:序列化后的外层日志数组,加上完成值或失败消息的组合载荷。固定的结果封装语法与 Consumer 展示空白不计入这份计量——也就是说你不用担心格式化开销吃掉了你的预算。
- 超限或完成值不合法都会显式失败(
output-limit/invalid-output),而不会在值里插入替代内容。这一点和 21-文件访问的三层管控里”截断要说清楚”是同一种态度:悄悄塞个...进去,模型会以为那就是全部。
两个只读描述符
服务上还有两个描述符,是给消费者做展示与前置判断用的:
language:程序必须使用的语言。已知值是'typescript'和'python'——TypeScript 后端已发布,Python 后端为实验性且私有(未发布)。它只是信息、不做门禁:生成语言相关展示的消费者要据此切换,遇到自己无法展示的语言时应显式报错,而不是硬渲染。27-一批任务的并行编排那条 workflow 路子的实现(@deepseek-ai/dsh-workflow-ptc)就是这么用的:运行时不是 TypeScript,它直接拒绝。isolation:执行基底,取值'worker-thread'/'process'/'container'。这个字段有个必须点明的限制——它是仅为诊断标签,不构成安全承诺。别看着container就以为得到了沙箱隔离的保证;安全边界是 21-文件访问的三层管控讲的那三层(沙箱、观察策略、权限预设)在管,PTC 这边真正做约束的是那次执行的sandboxPolicy。
另外两条实现在自身上的约束:各次运行必须彼此隔离(无跨运行状态);dispose 时要等系统完全停稳——teardown 会等到所有进行中的运行都已经终止并结算才完成。
子进程
代码执行是一条缝,子进程是另一条,两者常一起用。
子进程 seam 的消费者不是插件,而是其他能力 seam 与进程外后端:bash 执行器家族用收集模式的批量输出、LSP 用原始协议管道、PTY 后端用终端原语、ACP subagent 后端用管道传输的 ndjson 并让 stderr 走 inherit。所以它是底座,不是给模型直接用的工具。
它拥有三样东西,其中第一样最容易被忽略:
受管的 DSH_* 环境命名空间。 规则是:实现会在合并调用方显式 env 之前,丢弃环境中已有的 DSH_* 名称。所以当前事实只会以有意提供的字符串条目形式到达子进程。反过来,显式的 undefined tombstone 会删除普通环境中已有的值——这是一条正经的”删除环境变量”的路子,而不是”给个空字符串”。
子进程环境的合并规则:
共享的凭据清除(scrubbedParentEnv)。父进程的环境不会原封不动传下去。
CollectedOutput 形状。 每条被收集的流都报告自身的截断与 spill 恢复状态:text 在截断时是流的尾部,truncated 标明有没有丢字节,spillPath 指向装着完整流的文件(截断且可用时)。
可执行文件的查找走 resolveExecutable(command, env?, signal?):要么是验证过的绝对路径,要么通过提供方清理后的 PATH 加有意覆盖来解析裸名称。
还有一句描述解释了”显式 spawn”这个说法的由来:一个提供方的 spawn 工作目录、可执行文件路径、普通进程与终端会话,和挂载的文件系统提供方处于同一路径与进程命名空间。也就是说子进程看到的世界和你挂的文件系统是同一套坐标——这是它能被权限与沙箱策略有效约束的前提。
跑一段代码
工具侧现成的那条路是 run_code:PTC 预设里,@deepseek-ai/dsh-agent-tool-presentation 会以 mode: ptc 声明这个 agent 的工具呈现方式,于是 @deepseek-ai/dsh-tools 只把 run_code 一个工具交给模型——它把模型的工具表作为名为 tools 的绑定交给程序,并声明只有 run_code 能被直接调用。这个工具名被运行时保留,别的插件既不能注册也不能遮蔽它。
想在自己的插件里跑程序,就直接消费 ctx.ptcRuntime。demo dsh-code-lab 的核心就是一次运行加上对结果的正确处理:
// 请求进,规格出:resolve 校验支持的选择、套上部署默认值与上限。
const spec = ctx.ptcRuntime.resolve({
program,
// 绑定 = 暴露给这段程序的全局变量,一个命名空间一个全局对象。
// 只暴露该给的,别把整个 ctx 递进去。
bindings: [{
global: 'lab',
functions: {
now: async () => new Date().toISOString(),
// 绑定函数只收一个参数:程序那边写成 lab.upper({ text: 'x' })。
upper: async (args: unknown) => {
const text = (args as { text?: unknown }).text
return typeof text === 'string' ? text.toUpperCase() : ''
},
},
}],
// 省略 timeoutMs 用提供方默认值;给了数值会被截到部署上限。
...timeoutMs === undefined ? {} : { timeoutMs },
signal: exec.signal,
})
// run 拿的是规格,不是请求:目录与截止时间在规格里已经明确。
const result = await ctx.ptcRuntime.run(spec)
注意没有 try/catch——因为失败不会走出异常路径:
// 失败是字段,不是异常——所以这里不需要 try/catch。
if (result.error !== undefined) {
// 每个分类都值得原样告诉模型:它据此知道下一步该改什么。
return `运行失败(${result.error.kind}):${result.error.message}`
}
那行 return 里把 kind 和 message 都带上,是因为这两样都是给模型自我修正用的。只报一句”运行失败”等于把它能用的信息全丢了。
最后把两个描述符一并报出去,便于排查”为什么这段在别的机器上跑不了”:
return JSON.stringify({
打印输出: result.logs.join('\n'),
返回值: result.value ?? null,
// 基底与语言是服务上的只读描述符,报出来便于排查「为什么这段跑不了」。
语言: ctx.ptcRuntime.language,
执行基底: ctx.ptcRuntime.isolation,
}, null, 2)
验证
cd code/dsh_plugin/dsh-code-lab && ./install.sh
四步,后两步是重点:
# 1. 配置对不对(一行:我们的工具;PTC 运行时提供方由 dsh-base 配置)
dsh --profile test --dump-config | grep -A3 'id: code-lab'
# 2. 跑一段正常的
dsh --profile test "用 run_snippet 算一下 1 到 100 的和,把返回值打印出来"
# 3. 跑一段会抛异常的
dsh --profile test "用 run_snippet 运行 throw new Error('boom')"
# 4. 跑一段死循环,并把预算显式压到 5 秒,省得等提供方的默认值
dsh --profile test "用 run_snippet 把 timeoutMs 设成 5000,运行 while (true) {}"
第三步应当返回 运行失败(exception),第四步返回 运行失败(timeout)。
这两个必须不一样。 如果它们都变成一句”运行失败了”,模型就没有任何线索去区分”我代码写错了”和”这个任务太大”——而这两种情况的下一步动作完全不同。
注意事项
- 请求与规格是两步。
resolve套默认值与上限,run只执行规格;run不会替你补目录或截止时间。 - 失败是字段,不是异常。
run()总是不被拒绝地兑现,错误在result.error上。用try/catch去接它反而会盖住真正的异常。 - 别把八个分类压成一个。 它们的价值就在于各自指向不同的自救方式。转发给模型时要带上
kind与message。 - 预算是请求里能调的,但会被截到部署上限。 省略用提供方默认值,
null是明确要求不设截止;服务上的timeout报出这套默认与上限。 signal是硬停止,但不收回进行中的绑定调用。 那些调用的结算归调用方。写绑定函数时要自己处理中止。- 绑定给多宽是个安全决定。 一个命名空间一个全局对象,递进去的东西模型都能直接用。别把整个
ctx递进去。 - 绑定名要可移植,值要无损 JSON。
global得是各目标语言都合法的标识符,参数与返回值都得能无损序列化。 isolation不是安全承诺。 它只是诊断标签。真正的边界在沙箱、观察策略与权限预设那三层。- 超限与非法完成值都会显式失败,不会静默替代。 这是好事:静默插入的省略号会让模型以为那就是全部内容。
- 别依赖不同输出通道之间的相对顺序。 每个通道内部有序,通道之间的交错由后端决定。
DSH_*归 Harness 所有。 调用方提供的同名变量会被丢弃;要删一个继承来的普通环境变量,用显式的undefinedtombstone,而不是空字符串。- Python 后端还没发布。 已知语言值里有它,但当前只有 TypeScript 后端可用;写展示逻辑时遇到不认得的语言要显式报错。