Skip to content

dsh-ptc-moejs

Verified

dsh-ptc-moejs · v0.2.2 · MIT

PTC runtime for the DeepSeek Harness that runs model-written programs in moejs, a pure-Go JavaScript engine, inside a long-lived sidecar process under dsh's file sandbox

Install

dsh plugin add dsh-ptc-moejs

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Creators

Readme

moejs

dsh-ptc-moejs

DeepSeek Harness 的 PTC 运行时,用 Go 写的 JavaScript 引擎 moejs 运行模型写的程序。

简体中文 | English

DeepSeek Harness(dsh)的 PTC 模式下,模型给出一段调用工具的 TypeScript 程序。这个运行时让 sidecar 进程常驻,每段程序在里面拿到一个新的 moejs 运行时。调用三次工具的程序在这里用时 0.4 ms。dsh 的 Node 运行时每段程序都要启动一个 Node 进程,同样的程序用时 110 ms。

程序可以等待定时器,可以从工作目录导入辅助模块,可以用 node:path、node:util 和 node:timers。dsh 的 workflow 工具也能在这里运行它的脚本。

程序对机器做的每件事都是一次工具调用,dsh 按该工具自己的策略、审批和沙箱执行这次调用。sidecar 本身只读取程序导入的代码文件,它受到的限制和 dsh 的 Node 运行时给程序的一样:会话文件策略的沙箱,以及同样精简的环境变量。运行到了截止时间就会停下,死循环里也一样,分配的内存超过预算时也会停下。

性能

两个运行时挂载在 dsh 自己的服务上,通过 dsh 的 PtcRuntime 服务接口运行同一段程序:三次工具调用,每次由绑定函数立即返回,最后返回一个对象。

运行时 每次调用 从加载到第一个结果
dsh-ptc-moejs,workspace-write 0.42 ms 16.4 ms
dsh-ptc-moejs,danger-full-access 0.42 ms 6.8 ms
dsh-ptc-runtime-node,workspace-write 110.4 ms 164.4 ms
dsh-ptc-runtime-node,danger-full-access 103.0 ms 149.2 ms

一次调用是一次 resolve 加一次 run,从程序文本到结果。workspace-write 是 dsh 会话的默认文件策略,这时两个运行时都在 bubblewrap 里启动进程:Node 运行时每段程序一个进程,moejs 是许多段程序共用一个 sidecar。danger-full-access 下直接启动。从加载到第一个结果是从挂载插件到第一次运行结束,moejs 的这段时间包含启动 sidecar。

每一轮里,每个运行时都在新的 Node 进程中启动。5 轮交替顺序运行,环境是 Intel Core i5-13500H、Linux 6.18、Node 24.22.1、Go 1.26.6、dsh 0.2.1-rc.2 和 moejs v0.1.0-alpha.9。表中是各轮中位数的中位数,moejs 的各轮中位数在 0.37 到 0.46 ms 之间,Node 在 100 到 114 ms 之间。

一次长的 agent 对话

bench/agent.mjs 在 headless dsh 里分别用两个运行时跑同一段对话。一个按脚本应答的模型发起 40 次 run_code,对象是五个 dsh 包的副本,这些程序一共调用 90 次 dsh 的工具:glob、read、grep 和 bash。两个运行时都在同一台机器上、在 workspace-write 下运行。表中是每个运行时 3 段对话的平均值。

dsh-ptc-runtime-node dsh-ptc-moejs
run_code 耗时 8.6 s 3.6 s
其中嵌套的工具调用 3.5 s 3.3 s
整段对话的墙钟时间 10.1 s 5.0 s
dsh 及其子进程的 CPU 时间 8.4 s 5.2 s
dsh 及其子进程的内存峰值(PSS) 440 MiB 437 MiB
PTC 运行时进程的内存峰值(PSS) 34.0 MiB 18.5 MiB
PTC 运行时的进程数,含 bubblewrap 的进程 120 3

每段程序在 moejs 上少用 125 ms,按程序不同在 89 到 159 ms 之间,CPU 时间少 82 ms,没有一段程序更慢。工具调用在两个运行时上用时差不多,差别在于 Node 运行时为每段程序在 bubblewrap 里启动一个 Node 进程。两个运行时的内存峰值差不多,其中约 385 MiB 是 dsh 进程本身。Node 运行时给每段程序的进程最高到 34 MiB,程序结束就退出;moejs 的 sidecar 和它的 bubblewrap 进程在整段对话里一直运行,最高 18.5 MiB。

一天的 agent 工作

节省的时间随程序的数量增长。按脚本应答的模型会立即回复,真实模型每一步的时间在两个运行时上一样多,所以省下的是 agent 等程序结果的时间。下表把实测的每段程序 125 ms 和 82 ms CPU 时间乘以一天的程序数,举例时假设每 10 秒运行一段程序。

每天的程序数 例如 少等的时间 节省的 CPU 时间
200 半小时的 agent 工作 25 s 16 s
1,000 约 3 小时 2.1 min 1.4 min
2,880 一个 agent 连续工作 8 小时 6.0 min 3.9 min
11,520 四个这样的 agent 并行 24 min 16 min

连续工作 8 小时的 agent,在 Node 运行时上有 10.3 分钟花在 run_code 上,在 moejs 上是 4.3 分钟。要估算自己的情况,把你的 agent 一天运行的程序数乘以 125 ms。

功能

对机器的每次操作都是工具调用

程序能调用 dsh 提供的工具,能向 console 输出。读文件、执行命令、抓取网页都要调用工具,dsh 用工具流水线执行这次调用,由该工具自己的策略、审批和沙箱处理,和 Node 运行时一样。dsh 的会话日志记录每一次调用。sidecar 自己读取的只有程序导入的代码文件,读取时受这次运行的策略的沙箱限制。

和 Node 运行时一样的沙箱

sidecar 受到的限制和 Node 运行时的程序进程相同:

  • 策略就是 dsh 为这次运行解析出来的策略:会话的文件模式和它的工作区根目录;没有会话的运行用部署的默认策略。
  • dsh 的沙箱服务用本机的 runner 限制 sidecar:Linux 上是 bubblewrap 或 Landlock,macOS 上是 Seatbelt,Windows 上是受限令牌 runner。danger-full-access 下 sidecar 直接启动,不经过 runner。
  • 受限模式在无法限制时直接失败。没有 runner 能限制 sidecar 时,运行以 sandbox-unavailable 失败,和 Node 运行时一样,所以在 bubblewrap 和 Landlock 都用不了的容器里要用 danger-full-access。
  • dsh 进程的环境变量里,sidecar 只保留 PATH、PATHEXT、SYSTEMROOT、WINDIR、TEMP 和 TMP,也就是 Node 运行时保留的那些,另外保留 ELECTRON_RUN_AS_NODE,这样 dsh 桌面版的 Electron 才能运行 Windows 沙箱 runner。API key 不会进到 sidecar 里。
  • 会话的文件模式改变时,这个会话里按旧模式进行的运行会被停止。

在 Linux 上,插件自己打开 sidecar 的二进制文件,加了沙箱的 sidecar 从这个打开的文件运行,所以即使沙箱藏起了二进制所在的目录(比如 workspace-write 下 bubblewrap 会把 /tmp 换成空目录),sidecar 也能启动。这样的 sidecar 在 ps 里显示为 /proc/self/fd/3。

每个在用的文件策略有自己的 sidecar,模式和工作区根目录相同的会话共用一个。沙箱作用在 sidecar 进程上。除了读取它导入的文件,程序自己不能访问文件、网络和进程,所以策略决定的是工具调用能做什么,而不是程序本身做什么。

加了沙箱的命令由插件自己启动。dsh 的子进程服务启动 Node 运行时的进程时,在 Linux 上还会先建一个 systemd scope、经过一个 Node 引导进程,用来回收程序留下的子进程。这会让每次启动多花约 80 ms,而 sidecar 不会启动子进程。sidecar 总是运行在运行 dsh 的这台机器上:执行环境在另一台机器上的部署(例如通过 SSH)里,受限模式会以 sandbox-unavailable 失败。

每次运行一个新运行时

默认策略的 sidecar 在插件加载时启动,每个 sidecar 在两段程序之间一直运行。连续五分钟没有运行时它会退出,下一次运行再启动。每次运行拿到自己的 moejs 运行时,每段程序都从干净的全局环境开始,同一个 sidecar 里可以同时进行多次运行。

每次运行都有上限

一次运行默认可以分配 512 MiB,垃圾也算在内。截止时间到了、dsh 中止或者 profile 关闭时,运行立即停止。console 输出、返回值和发给 sidecar 的每条消息各有大小上限,见配置。

和 Node 运行时一样的 TypeScript

程序是只用可擦除语法的 TypeScript,规则和 dsh 的 Node 运行时相同:类型标注被删掉,enum、带值的 namespace 和参数属性都是语法错误。程序是一个 async 函数的函数体,顶层可以用 await 和 return。

定时器

setTimeout、setInterval、setImmediate 和对应的 clear 函数和 Node 里的一样,Timeout 对象有 ref、unref、hasRef、refresh 和 close,node:timers/promises 提供它们的 promise 形式。程序可以在两次工具调用之间等待、轮询,或者按退避间隔重试:

const { setTimeout: sleep } = await import("node:timers/promises");
for (let attempt = 1; attempt <= 10; attempt++) {
  const status = await tools.bash({ description: "Check the build", command: "cat build/status" });
  if (status.stdout?.text.trim() === "done") return "built";
  await sleep(2000);
}
return "still building";

定时器回调在程序的运行时里执行,所以算进它的内存预算和截止时间。程序返回时还没触发的定时器不会再执行,就像 Node 运行时的进程随程序一起结束一样。

模块

程序用 await import(...) 加载模块:

  • 工作目录下的 .ts、.mts、.js 和 .mjs 文件,写出扩展名,用相对工作目录的路径、绝对路径或 file: URL 指定。被导入的模块可以用 import 声明导入其他文件,可以用 console 和定时器。工具绑定由程序作为参数传给它。
  • node:path、node:util、node:timers、node:timers/promises 和 node:vm,不写 node: 前缀也可以。
  • JavaScript 的 data: URL。
const { summarize } = await import("./scripts/summarize.ts");
return await summarize(tools, "docs/");

被导入的文件由 sidecar 自己读取,读取时受这次运行的沙箱限制。同一份内容只编译一次:之后的运行导入同一个没改过的文件时不再编译。工作目录之外的路径、指向工作目录之外的符号链接、包名、JSON 文件和其他 Node 模块都会失败,错误消息说明原因。只有 dsh 的执行环境就是运行 dsh 的这台机器时,才能加载文件。执行环境在另一台机器上时(例如通过 SSH),内置模块仍然可以加载。

Workflow

dsh 的 workflow 工具可以在这个运行时上运行。它的 guest 从 data: URL 加载,通过 node:vm 运行每个 workflow 脚本。这里的 node:vm 在程序自己的运行时里运行脚本,context 对象的属性就是脚本的全局变量。脚本里的 agent()、parallel()、pipeline()、phase() 和 log() 和在 Node 运行时上一样。

一个静态程序

sidecar 是用 CGO_ENABLED=0 构建的自包含 Go 程序。包管理器以可选依赖的形式安装对应平台的程序。

和 Node 运行时的区别

程序能用 JavaScript 标准库、工具、Node 的定时器和五个 Node 模块:node:path、node:util、node:timers、node:timers/promises 和 node:vm。require、process、Buffer、fs、包和其他 Node 模块都用不了,程序要读文件内容时调用 read 工具。如果你的程序要用其他 Node API,这个 profile 继续用 dsh 的 Node 运行时。

在 Node 运行时上,程序里的 import("./helper.ts") 相对 dsh 自己的引导文件解析,所以那里的程序要用绝对路径导入文件。这里两种写法都可以,相对路径相对工作目录解析。

node:vm 在程序自己的运行时里运行脚本。脚本的 timeout 选项不生效,不会结束的脚本由这次运行的截止时间停止。脚本里的 var 和 function 声明留在脚本自己的作用域里,给 context 上没有的名字赋值会在程序的运行时里创建全局变量,而 Node 会把这两者都放到 context 上。所以死循环的 workflow 会以它的 run_code 调用的 timeout 结束,而 Node 运行时报告的是脚本自己的超时。这类脚本的栈帧显示的是 node:vm,而不是脚本的文件名。

run_code 和在 Node 运行时上一样接受 sandbox_permissions 和 justification,批准后程序在放宽模式的 sidecar 里运行。除了读取它导入的文件,程序自己不做文件访问,所以这次批准不会让它多做任何事。文件沙箱拒绝某次工具调用时,程序在这次调用的参数里带上 sandbox_permissions 和 justification 重试,每次这样的调用都要你批准。用 Node 运行时的话,对 run_code 的批准还会放行程序通过 Node API 自己做的文件操作。

快速上手

dsh plugin --profile web add dsh-ptc-moejs

把 web 换成你使用的 profile 名。sidecar 程序以可选依赖的形式提供,覆盖 darwin-arm64、darwin-x64、linux-arm64、linux-x64 和 win32-x64,安装时需要启用可选依赖。

安装后 profile 里多出一行处于禁用状态的 ptc-moejs。要启用它,把下面的补丁加进 profile 的 cordis.patch.yml,路径是 ~/.dsh/profiles/<name>/cordis.patch.yml(设置了 $DSH_HOME 时在它下面的同一路径):

# 用 moejs 替换 Node PTC 运行时。
- id: ptc-runtime
  disabled: true
- id: ptc-moejs
  disabled: false
# 打开 PTC 模式。
- id: tools
  config:
    mode: ptc

dsh --profile web --dump-config 会打印组合后的配置行。在 web 和 headless profile 里,也可以用 DSH_TOOLS_MODE=ptc 在单次启动时打开 PTC 模式,代替 tools 那一项。要换回 Node 运行时,删掉前两项。

配置

选项写在同一个补丁的 ptc-moejs 行上:

- id: ptc-moejs
  disabled: false
  config:
    memoryLimitMb: 1024
    timeoutMs: 300000
选项 默认值 含义
timeoutMs 120000 模型没有指定时,一次运行的截止时间(毫秒)。工具调用和审批的时间都算在内。
maxTimeoutMs 600000 一次运行可以请求的最长截止时间。
memoryLimitMb 512 一次运行在整个生命周期里可以分配的 MiB 数,垃圾也算在内。超过后运行以 timeout 停止,消息里写明是哪个预算。
maxLogBytes 16 MiB 一次运行保留的 console 输出字节数,按 dsh 存储的 JSON 数组计算。超出部分被丢弃,日志以一行标记结尾,运行继续。
maxValueBytes 16 MiB 返回值序列化成 JSON 后的字节数。更大的值让运行以 output-limit 失败。错误消息也截断到这个长度。
maxPendingCalls 128 一次运行同时进行中的工具调用数。再多一个,运行以 protocol 失败。
maxPendingTimers 65536 一次运行同时挂着的定时器和 immediate 数。再多一个,程序里抛出 RangeError。
maxFrameBytes 64 MiB dsh 和 sidecar 之间单条消息的上限:一段程序、一次工具调用的参数或一个工具结果。更大的工具结果在程序里表现为被拒绝的调用。

截止时间的默认值和 dsh 的 Node 运行时一致。dsh 会把超过 12,500 token 的工具结果转存到文件,所以日志和返回值的预算用来限制内存占用。

程序能看到什么

程序是一个 async 函数的函数体。函数的参数有 dsh 传入的绑定命名空间(PTC 模式下是 tools)、每个命名空间的错误类(ToolCallError,toolName 属性里是工具名),以及带 log、info、warn、error、debug 的 console。console 也是全局变量,setTimeout、setInterval、setImmediate 和对应的 clear 函数也是,所以程序导入的模块也能用它们;绑定命名空间不是全局变量。其余是 JavaScript 标准库,moejs 的标准库里有 TextEncoder、TextDecoder、atob、btoa、structuredClone 和 queueMicrotask。

模型写出的程序是这样的,运行时把返回值交给 dsh:

const head = await tools.bash({ description: "Read the last commit", command: "git log -1 --oneline" });
return { head: head.stdout?.text };

返回值以无损 JSON 的形式交给 dsh:普通对象、数组、字符串、有限数字、布尔值和 null。值里含有 undefined、函数、BigInt、循环引用、NaN、Infinity、-0、Date、Map 或类的实例时,运行以 invalid-output 失败,消息给出第一个这样的成员的路径,例如 a BigInt at $.items[3].size。程序没有返回值时,运行正常结束,结果里没有值。工具参数遵守同样的规则,参数不是无损 JSON 的调用在发给 dsh 之前就在程序内部被拒绝。

失败类型 什么时候出现
exception 程序或定时器回调抛出异常、有 promise 被拒绝却没有处理、程序有语法错误,或者用了会改变运行行为的 TypeScript,例如 enum。
timeout 超过截止时间,或者分配的内存超过 memoryLimitMb。
abort dsh 取消了这次运行、profile 关闭了运行时,或者会话的文件模式改变了。
invalid-output 返回值不是无损 JSON。
output-limit 返回值大于 maxValueBytes。
protocol 同时进行中的工具调用超过 maxPendingCalls,或者 sidecar 发来了格式错误的消息。
sandbox-unavailable 文件模式需要沙箱,但本机没有 runner 能限制 sidecar,或者 runner 在 sidecar 运行之前就失败了。
worker-exit sidecar 进程退出了或者没能启动。下一次运行会启动新的进程。

和 Node 运行时一样,结果里还带有 sidecar 所在沙箱的情况:模式,以及受限模式下 runner 是完整还是部分执行了这个模式。

限制

  • 每次调用都从新的运行时开始。在调用之间保留变量的持久模式(类似 Node 运行时的 REPL 模式)计划在以后的版本中加入。
  • 内存预算统计的是一次运行分配的内存,垃圾也算在内。一次运行如果反复创建又丢弃大量数据,可能在存活数据达到预算之前就被停止。
  • 两个方向的值最多嵌套 10,000 层,这是 moejs 的 JSON 读写能处理的深度。
  • console 用移植自 Node 22 的 util.inspect 渲染值,选项和 dsh 的 Node 运行时一样:深度 4、100 项、字符串 10,000 个字符。Node 24 写对象的 symbol 键时不带方括号。Node 的 util.inspect 能用 showProxy 显示 proxy 的目标,能显示迭代器和 WeakMap 的条目,moejs 不提供这些。
  • 一次运行最多导入 256 个文件,每个最大 1 MiB,合计最大 8 MiB。moejs 没有 import attributes,所以没有 JSON 模块。程序是函数体,所以程序里用 import() 导入,import 声明在被导入的模块里可以用。
  • moejs 的中断作用于正在运行的 JavaScript,编译一旦开始会一直进行到结束。解析很慢的程序到截止时间时,dsh 一侧照常结束这次运行,sidecar 等解析返回后丢弃它。
  • 一个 promise 被拒绝后,到它所在回调的 promise 任务跑完时仍没有处理函数,这次运行就会失败,就像未处理的拒绝会结束 Node 运行时的进程一样。程序没有 await 的工具调用在工具失败时会这样让运行失败,async 定时器回调里的异常也一样。消息以 Uncaught (in promise) 开头。
  • 程序第一行上的栈帧,列号从程序所在函数的开头算起。运行失败的消息和 console 给出的是程序自己的列号;程序自己读取的错误 stack 仍从函数开头算。
  • CI 每次推送都在 Linux x64、Linux arm64、macOS arm64 和 Windows x64 上跑测试。macOS x64 还没测过,它的程序由同一份源码交叉编译。

开发

pnpm install
pnpm run build:sidecar   # 为本机构建 sidecar,放到 npm/<package>/bin
pnpm run build           # lib/
pnpm test                # 单元测试,以及在 dsh 自己的服务上针对 sidecar 的行为测试
go test ./...            # sidecar 的 Go 测试
DSH_REPO=/path/to/deepseek-harness pnpm run e2e     # 用 dsh 的 mock LLM 跑 headless dsh
DSH_REPO=/path/to/deepseek-harness pnpm run bench   # 上面的测量
DSH_REPO=/path/to/deepseek-harness pnpm run bench:agent   # 长对话测评,需要 Linux
DSH_REPO=/path/to/deepseek-harness pnpm run bench:features   # 在两个运行时上测定时器、模块和 dsh 的 workflow guest
DSH_REPO=/path/to/deepseek-harness pnpm run compare  # 在两个运行时上跑同样的程序,列出不同之处
pnpm run build:binaries  # 五个平台的程序

构建 sidecar 需要 Go 1.25 或更新版本,使用 CGO_ENABLED=0。测试挂载 dsh 的 fs、沙箱和策略包,本机有可用的 runner 时,其中一个测试会在本机沙箱里运行程序。DSH_REPO 是执行过 pnpm install 和 pnpm run build 的 dsh 源码目录。端到端测试用 dsh plugin add 把这个目录装进临时 profile,跑一个 headless 任务,让 mock 模型调用 run_code,再和 Node 运行时跑同一任务的会话日志做比较,包括提供给模型的 run_code 参数。compare 在这个运行时和 Node 运行时上各跑约一百段程序,用 Markdown 报告列出两边日志、返回值和失败的不同之处。

许可证

MIT。各平台包里的常驻进程二进制还包含 moejs(Apache License 2.0)、Go 标准库(BSD 3-Clause License)以及移植自 Node.js 的 path 模块和 util.inspect(MIT License),它们的许可证原文在 THIRD_PARTY_NOTICES.md 里,随每个平台包一起发布。