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 的三层结构
agent-preset-registry(host 平面):管理「哪个会话用哪套 preset」。web-app bundle 里它的配置是default: standard。agent-preset(agent 平面):一个普通 Cordis 插件,把config.plugins声明成一段子插件行列表并挂载。声明行的id是 Loader 的地址,config.id才是会话保存的 preset 身份。persona等子插件:preset 里真正生效的那些行。agent-tool-presentation(agent 平面):一行tool-presentation,作用是ctx.tools.presentAs(mode),声明挂载作用域内的工具呈现形态。工具注册表本身留在 host 平面(调度器、API 代理、所有工具插件都是它的消费者),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 行)只有两行:
persona,config 是{ prefix: 'You are a helpful software engineer assistant.', complete: true, includeRuntimeContext: false }——complete: true表示「这就是全部系统提示词」,因此 host 侧的 runtime context 也被关掉。persistent-shellgroup(isolateterminals: true),挂@deepseek-ai/dsh-terminal(pty)与四个 shell 工具行,其中terminal-bash/persistent-bash在 win32 下 disabled,terminal-pwsh/persistent-pwsh在非 win32 下 disabled。
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 时:
- 发给模型的工具 schema 只剩
run_code一个,加一段生成的 SDK 提示词; - 执行侧同样折叠——模型直接调用任何非
run_code的名字都会失败(index.ts: PTC_ONLY_INSTRUCTION); - 但
run_code内部的子调用保留全部可见工具。
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]> }
三个设计点:
- 按 name 字典序排序,注释明说是为了「an unchanged tool set produces byte-identical text across assemblies」——字节稳定,请求缓存与快照测试都靠它。
- 每个工具渲染两份类型:入参来自
schema.parameters,输出不是从工具描述里猜的,而是带在ToolSdkSchema.output(由defineTool的output.schema提供)。 - 开头是一段固定的使用契约(
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 })
}
关键事实:
subCallId是${rootCallId}:ptc:${n},n 是本次 run 内的递增序号,因此子调用的 callId 与真实工具调用同样唯一且可追溯。- 子调用走的是同一个
registry[TOOL_RUNTIME_SCHEDULER],即prepare → dispatch → finalize/finish四段与原生路径完全一致——权限、审批、超时、tools/pre-execute/tools/post-execute策略全部生效。 - 调度契约是「同构」的:注释明确说「every ordered stage (the dispatch-start append, prepare = pre-execute/guards, finalize/finish = post-execute, context deferral, the settle append) runs inside ONE driver lane」,只有 around-dispatch/body 阶段并发。容量判据
capacity = !exclusiveActive && (mode === 'exclusive' ? inFlight.size === 0 : inFlight.size < maxParallel),maxParallel来自Config.maxParallelSubCalls(默认 10,与DEFAULT_MAX_PARALLEL_TOOL_CALLS同值)。 - 分类在每次启动前重读(
classify: () => registry.executionMode(input).kind),与原生fillPool一样支持「排队期间注册表变化导致调用变 exclusive」。 - 旁路日志:
tool/ptc-dispatch-start在start()里 append,tool/ptc-dispatch在 settle 后的logWork集合里 append。注释说明这个 append 是「tracked side work」,不占 dispatch slot——程序立刻拿到返回值,日志写入是并发的后台工作;while (logWork.size > maxParallel) await Promise.race(logWork)提供背压。 - 图像结果延后附加:成功且含 image block 的子结果通过
exec.deferContext(createUserMessage({ content, source: { kind: 'ptc-mode' } }))挂到下一个 step 边界,其余中间结果不进对话。 - 超时与升级:
timeoutMs与sandbox_permissions+justification是一对参数,后者经approveEscalation({ subject: 'program' })走审批;CodeRunFailedError(code: 'CODE_RUN_FAILED')把失败种类、捕获的日志、沙箱事实一起交给模型。
六、程序跑在独立 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 参数里显式传入。
自测题
standard与ptc只差三行。请说明为什么workflow-ptc与tool-workflow需要被关掉,而不是让它们与run_code共存。renderToolsSdk对工具名排序,注释说排序「is not a total order on byte-equal names」。请说明这个前提为什么成立,以及如果输入里出现两个同名 schema 会发生什么。run_code的description与parameters用 getter 延迟计算,而defineTool的参数校验用的是静态 spec。请解释为什么校验可以不做语言区分,而呈现必须做。binding里tool/ptc-dispatch的 append 被放在logWork而不是pendingQueue。如果改成占用 dispatch slot,会出现什么可观测的行为差异?NodePtcRuntime先ctx.sandbox.confine(argv, policy)再ctx.subprocess.spawn({ argv: confined.argv })。请说明如果调换这两个顺序(先 spawn 再 confine)为什么在语义上不可能成立。