KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

05 · 四种 preset 与 Code Mode:把工具编译成一段 TypeScript 程序 — keel 龙骨

packages/bundle/web-app/presets/ 下有四个 .patch.yml:standard、ptc、minimal、cordis。它们不是四套代码,而是四份配置补丁——每个文件只做一件事:往 profile 树里 insert 一条 @deepseek-ai/dsh-agent-preset 声明。这一章先用逐行 diff 讲清楚这四个 preset 的差异,再讲 ptc 背后的 Code Mode 是怎么把 N

packages/bundle/web-app/presets/ 下有四个 .patch.yml:standard、ptc、minimal、cordis。它们不是四套代码,而是四份配置补丁——每个文件只做一件事:往 profile 树里 insert 一条 @deepseek-ai/dsh-agent-preset 声明。这一章先用逐行 diff 讲清楚这四个 preset 的差异,再讲 ptc 背后的 Code Mode 是怎么把 N 个工具折叠成 1 个 run_code 的。

一、preset 的三层结构

四个 preset 的 config.order 与 config.id 一一对应:standard=1、ptc=2、minimal=3、cordis=4。本 commit 里没有 id 为 creator 的 preset——cordis 就是第 4 个(agent-preset 的 README 用「creator mode」描述它挂载的技能,但 preset id 是 cordis)。

二、standard vs ptc:只差三处

diff standard.patch.yml ptc.patch.yml 去掉首尾注释与 id/order 两行后,只剩下三处差异:

120a121  workflow-ptc          disabled: true
124a126  tool-workflow         disabled: true
141a144  tool-presentation     name: '@deepseek-ai/dsh-agent-tool-presentation'
                              config: { mode: ptc }

也就是说 ptc 与 standard 在其余 40 余行上逐字相同(包括 delegation group 里 5 个 subagent 行的 config、compaction group 的三行、planning group 里那段 500 字的 plan-mode 提示词)。差别只有一句:ptc 关掉了 workflow-ptc 与 tool-workflow(因为它们本身就是「用多步工作流编排工具」的另一条路,与 PTC 语义重叠),并把工具呈现切成 mode: ptc。

standard 里另外两个反直觉的默认值:tool-subagent-codex 与 tool-subagent-claude-code 都是 disabled: true(模型默认看不到这两个工具),tool-ralph 也是 disabled。backgroundMode 分别是 one-shot / continuable:

工具 provider backgroundMode 默认
subagent spawn continuable 启用
subagent_fork fork continuable 启用
subagent_codex codex one-shot disabled
subagent_claude_code claude-code one-shot disabled

三、minimal 与 cordis

minimal(order 3,61 行)只有两行:

cordis(order 4,154 行)在 standard 基础上做了四处改动:新增 tool-cordis;把 skill-filesystem 与 tool-skill 两行移到 tool-cordis 之后并给 skill-filesystem 加 customSkillDirs(用 node:module.createRequire(baseUrl) 解析 @deepseek-ai/dsh-agent-preset/package.json 旁的 skills/);persona.prefix 改成 >- 折叠标量(值不变,只是排版);tool-plugin-manager 的 disabled 从字面量 true 改成 !!js "!ctx.get('profileContext')"。

四、Code Mode = PTC:SDK 是编译产物

core/tools/src/index.ts: Config.mode 是 'native' | 'ptc' | 'both',默认 native。选 ptc 时:

SDK 文本由 core/tools/src/ts-types.ts: renderToolsSdk(schemas)(第 297 行)生成:

const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
// interface ToolArgsMap { … }
// interface ToolOutputMap { … }
// type ToolName = keyof ToolOutputMap
// declare class ToolCallError extends Error { readonly name: "ToolCallError"; readonly toolName: ToolName }
// declare const tools: { [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]> }

三个设计点:

  1. 按 name 字典序排序,注释明说是为了「an unchanged tool set produces byte-identical text across assemblies」——字节稳定,请求缓存与快照测试都靠它。
  2. 每个工具渲染两份类型:入参来自 schema.parameters,输出不是从工具描述里猜的,而是带在 ToolSdkSchema.output(由 defineTool 的 output.schema 提供)。
  3. 开头是一段固定的使用契约(SDK_INSTRUCTIONS + SDK_PROGRAM_INSTRUCTIONS),明确「tools.name(args) 调用」「失败抛 ToolCallError」「独立只读调用可以用 Promise.all 重叠」「只有 return/console.log 的内容是程序输出」。若工具集里恰好有 bash 且其 schema 接受示例字面量,还会追加一段可执行的 run_code({ code: "return await tools.bash({ command: 'pwd' })", … }) 示例(renderBashExample)。

jsonSchemaToTs(schema, indent)(第 240 行)负责 schema→TS 类型,用显式栈(SchemaRenderFrame)避免递归;遇到不支持的形状返回 'unknown' 而不抛。文档注释点明了这层依赖的方向:jsonSchema.ts: schemas()(native function calling)与本模块(生成 declare const tools 的 API)是同一个 store 的两种投影。

语言注册表在 index.ts: SDK_RENDERERS:

const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = {
  typescript: renderToolsSdk,
  python: renderToolsSdkPy,
} satisfies Record<PtcSdkLanguage, (schemas: ToolSdkSchema[]) => string>

PtcSdkLanguage = 'typescript' | 'python'(定义在 ptc.ts)。两张语言表(SDK_RENDERERS 与 RUN_CODE_FLAVORS)都被 satisfies 钉在这个联合上,因此加一门语言只改一处会 typecheck 失败。Python 渲染器在 py-types.ts: renderToolsSdkPy,对应运行时包是 experimental/ptc-runtime-python。

五、run_code 的子调用走同一套调度

ptc.ts: createRunCodeTool(registry, options) 构造 run_code 定义。它对每个可见工具生成一个 binding:

const binding = (schema: ToolSchema): PtcBindingFunction => async (rawArgs: unknown) => {
  const n = ++dispatches
  const subCallId = brandString<ToolCallId>(`${String(exec.callId)}:ptc:${n}`)
  const scheduler = registry[TOOL_RUNTIME_SCHEDULER]
  // pendingQueue.push({ start, commit, classify, abandon, flight, settled })
}

关键事实:

六、程序跑在独立 Node 子进程里

ptc-runtime-node 是 host 平面的服务(base bundle 里以 - id: ptc-runtime 挂载),NodePtcRuntime 的 static inject = ['fs', 'subprocess', 'sandbox', 'sandboxPolicy']。

启动参数在 ptc-runtime-node/src/launch.ts: bootstrapArgs(fs, config, maxMessageBytes):

if (config.bootstrapPath !== undefined) return [config.bootstrapPath, String(maxMessageBytes)]
if ('pkg' in process) return [String(maxMessageBytes)]
if (!new URL(import.meta.url).pathname.endsWith('.ts')) {
  return [mapped(fileURLToPath(new URL('./process.js', import.meta.url))), String(maxMessageBytes)]
}
const source = `const {openInheritedControlChannel}=await import(${JSON.stringify(pathToFileURL(helper).href)});…`
return ['--input-type=module', '--eval', source]

四种形态(预装 bootstrap / pkg 单文件 / 构建产物 / 源码)走不同 argv,都不继承宿主 loader 与 inspector 标志。

子进程侧 process.ts: runNodeMain(stream, maxMessageBytes, processState) 做两件事:

for (const key of Object.keys(processState.env)) {
  if (!STARTUP_ENVIRONMENT_NAMES.has(key.toUpperCase())) Reflect.deleteProperty(processState.env, key)
}
processState.env = Object.create(null) as NodeJS.ProcessEnv

environment.ts: STARTUP_ENVIRONMENT_NAMES = new Set(['PATH','PATHEXT','SYSTEMROOT','WINDIR','TEMP','TMP'])——只留启动原生可执行文件必需的变量,其余全部删除,然后换成 null-prototype 对象。配合 NodePtcRuntime.executionInstructions 里那句「process.env starts empty」,程序拿到的是一个干净环境。

NodePtcRuntime 的 schema 里还有几个值得记的默认值:timeoutMs 120_000、maxTimeoutMs 600_000、maxOutputBytes 64 MiB、maxMessageBytes 128 MiB、maxPendingCalls 128、graceMs 3_000,以及 readonly language = 'typescript' / readonly isolation = 'process'。

帧协议在 channel.ts: JsonChannel(带 maxMessageBytes 上限),握手顺序是:子进程先 channel.send({ type: 'ready' }) → 宿主回 boot 帧(ProgramBootData)→ 子进程 runProgram(...)。运行期宿主与程序通过 postMessage/on 双向传帧,done 帧是终态。

最后一步是沙箱:NodePtcRuntime.execute 里 confined = policy.mode === 'danger-full-access' ? undefined : await this.ctx.sandbox.confine(argv, policy, signal),然后 this.ctx.subprocess.spawn({ argv: confined?.argv ?? argv, … })。PTC 子进程与 bash 工具走的是同一条沙箱接缝,因此 Windows 上的 ACL runner、Linux 的 bwrap/landlock 对它同样生效。

代码地图

机制 位置 要点
standard preset packages/bundle/web-app/presets/standard.patch.yml 146 行,config.id: standard、order: 1
ptc preset packages/bundle/web-app/presets/ptc.patch.yml 与 standard 逐行同构,仅 3 处差异
minimal preset packages/bundle/web-app/presets/minimal.patch.yml 61 行,persona(complete/includeRuntimeContext:false)+ persistent-shell
cordis preset packages/bundle/web-app/presets/cordis.patch.yml 154 行,tool-cordis + 移动 skill 两行 + customSkillDirs
preset 默认值 packages/bundle/web-app/cordis.patch.yml: agent-preset-registry config.default: standard
呈现选择器 packages/core/agent-tool-presentation/src/index.ts ctx.tools.presentAs(mode),按挂载作用域生效
呈现模式 packages/core/tools/src/index.ts: ToolPresentationMode 'native' | 'ptc' | 'both',默认 native
子调用并发上限 packages/core/tools/src/index.ts: Config.maxParallelSubCalls 默认 10,1 即严格串行
SDK 渲染入口 packages/core/tools/src/ts-types.ts: renderToolsSdk 第 297 行;按 name 字典序保证字节稳定
schema→TS packages/core/tools/src/ts-types.ts: jsonSchemaToTs 显式栈,不支持即 'unknown',不抛
Python SDK 渲染 packages/core/tools/src/py-types.ts: renderToolsSdkPy 第 763 行
语言注册表 packages/core/tools/src/index.ts: SDK_RENDERERS satisfies Record<PtcSdkLanguage, …> 防漂移
语言联合 packages/core/tools/src/ptc.ts: PtcSdkLanguage 'typescript' | 'python'
run_code 定义 packages/core/tools/src/ptc.ts: createRunCodeTool 语言相关的 description/parameters 用 getter 延迟到投影时
子调用绑定 packages/core/tools/src/ptc.ts: binding subCallId = ${callId}:ptc:${n},走同一 scheduler
调度器符号 packages/core/tools/src/index.ts: TOOL_RUNTIME_SCHEDULER Symbol('@deepseek-ai/dsh-tools.scheduler')
调度四段 packages/core/tools/src/index.ts: ToolRuntimeScheduler prepare / dispatch / finalize / finish
启动参数 packages/ptc-runtime/ptc-runtime-node/src/launch.ts: bootstrapArgs 四种形态;源码态用 --input-type=module --eval
子进程主入口 packages/ptc-runtime/ptc-runtime-node/src/process.ts: runNodeMain 环境清洗 + ready/boot 握手
环境白名单 packages/ptc-runtime/ptc-runtime-node/src/environment.ts: STARTUP_ENVIRONMENT_NAMES 6 个变量
帧协议 packages/ptc-runtime/ptc-runtime-node/src/channel.ts: JsonChannel 带 maxMessageBytes 的 JSON 帧
运行时服务 packages/ptc-runtime/ptc-runtime-node/src/index.ts: NodePtcRuntime inject = ['fs','subprocess','sandbox','sandboxPolicy']

关键取舍

preset 是配置补丁而不是代码,代价是 standard 与 ptc 必须复制整份文件。
补丁语义是「整行替换 config」,所以哪怕只差三处也要维护两份 150 行文件,未来加一行要改两次。好处是 web 编辑器保存的改动可以按 id 精确回写,且「差异」本身就是可 diff、可 review 的事实——上面那三行 diff 就是证据。

PTC 把工具折叠成一个工具,代价是原生 function-calling 的可见性消失。
模型只看到 run_code,所有工具知识来自系统提示词里那段生成的 SDK。好处是长工具目录下的 token 开销与「工具选择错误」都下降,且可以用一段程序表达循环、条件、Promise.all。代价是模型必须理解生成的类型文本,且失败要通过 ToolCallError 的 try/catch 自行处理。

SDK 文本按 name 字典序生成,代价是工具集的呈现顺序不是「重要性顺序」。
renderToolsSdk 明确排序以保证字节稳定。好处是同一份工具集在任意装配顺序下产生完全相同的提示词,请求缓存与快照测试稳定。代价是提示词里的阅读顺序没有语义,插件无法通过注册顺序影响模型的注意力。

子调用复用原生 scheduler,代价是 run_code 内部也要维护一个单车道 driver。
binding 里那套 pendingQueue / commitQueue / exclusiveActive / drive() 是原生 runGroup 逻辑的再实现,只是为了在「程序主动 await」的模型下保持同一份顺序保证与 barrier 语义。好处是权限/审批/超时一处生效、两处一致。代价是两套调度代码要保持同步演进(maxParallel 的默认值就是刻意对齐的 10)。

子进程环境被清空,代价是程序不能直接用宿主环境变量。
process.env 变成空对象,只保留 6 个启动变量,且 assertTempRootOutsideWorkspace 之类的边界检查在沙箱侧另有实现。好处是「程序看到的机器」与「宿主看到的机器」解耦,replay 与沙箱语义都更稳定。代价是需要 API key、代理、工具链路径的场景必须在 run_code 参数里显式传入。

自测题

  1. standard 与 ptc 只差三行。请说明为什么 workflow-ptc 与 tool-workflow 需要被关掉,而不是让它们与 run_code 共存。
  2. renderToolsSdk 对工具名排序,注释说排序「is not a total order on byte-equal names」。请说明这个前提为什么成立,以及如果输入里出现两个同名 schema 会发生什么。
  3. run_code 的 description 与 parameters 用 getter 延迟计算,而 defineTool 的参数校验用的是静态 spec。请解释为什么校验可以不做语言区分,而呈现必须做。
  4. binding 里 tool/ptc-dispatch 的 append 被放在 logWork 而不是 pendingQueue。如果改成占用 dispatch slot,会出现什么可观测的行为差异?
  5. NodePtcRuntime 先 ctx.sandbox.confine(argv, policy) 再 ctx.subprocess.spawn({ argv: confined.argv })。请说明如果调换这两个顺序(先 spawn 再 confine)为什么在语义上不可能成立。

进入 keel 阅读