跳到主要内容

数据截至 (上游 commit 99f6f02fecdb)

第 6 章 · 每会话独立组装与产品外壳:presets、系统提示词、RPC 与多前端

30 秒导读: 前五章讲的是「一个 agent 怎么跑起来」。这一章讲最上层的两件事——同一个进程里,每个会话怎么拥有自己那一套工具和提示词;以及这套内核怎么长出 Web / CLI / SDK / ACP 四种产品外壳


1. 这一章要解决的问题

1.1 矛盾在哪

第 1 章说过:整个产品是一棵 Cordis 插件树cordis.yml 里写什么,进程里就有什么。

问题来了:一棵树是全进程唯一的。

但产品要的是:同一个进程里同时跑三个会话,会话 A 用「标准模式」(十几个工具),会话 B 用「PTC 模式」(工具收敛成一个 run_code),会话 C 用「极简模式」(只有 bashstr_replace_editor)。

如果工具注册表是全局的,三个会话看到的工具就一模一样。

1.2 解法两句话

  • 切视图,不切树。 引入一个叫 scope(作用域) 的东西:每个 agent 有一把不可见的钥匙,注册表按钥匙分层,读的时候只把「自己这一层 + 祖先层」合并出来。
  • 每套配置只装一份,会话去认领。 一个 preset(预设配置)在进程里只挂载一次,形成一份常驻挂载(standing mount);会话不重新装一遍,而是把自己的钥匙认到这份挂载名下,从此继承它的全部注册。

1.3 用起来什么样

用户视角就是 Web 界面上一个下拉框。仓库自带四个 preset,每个是 apps/cli/config/agent-presets/ 下的一个目录:

preset 目录显示名这套配置给的能力
standard标准模式完整编码 agent:文件编辑、Shell、检索、Skills、计划、目标、子代理、工作流
codePTC 模式与 standard 完全一致,只多一行——工具改用 Code Mode 呈现
cordis创造模式standard 全部能力 + 运行时自检 + preset 创作指导
minimal极简模式只有持久 bashstr_replace_editor,人格固定,无压缩

依据:apps/cli/config/agent-presets/{standard,code,cordis,minimal}/preset.yml


2. 顶层全景

2.1 一张图:三个平面

怎么读:从下往上是「越来越私有」。同一列的东西共享,不同列的东西互不可见。

┌──────────────────────────────────────────────┐
host plane │ 宿主平面(全进程一份,全局层) │
(第 1 章) │ 工具注册表本体 · 沙箱 · 审批 · 持久化 · 模型路由 │
└───────────────┬──────────────────────────────┘
│ registrations 向下继承
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ standing │ │ standing │ │ standing │ ← preset 常驻挂载
│ mount │ │ mount │ │ mount │ 每个 preset 只有一份
│ standard │ │ code │ │ minimal │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ 认父(bindScopeParent) │
┌───┴────┐ ┌─┴──┐ ┌──┴─┐
▼ ▼ ▼ ▼ ▼ ▼
会话A 会话B 会话C 会话D 会话E 会话F ← agent scope
(事件沿同一条链向上冒泡 ↑)

2.2 部件一句话职责

部件干什么在哪
dsh-scope造钥匙、连父子链、按链分层存/取、按链路由事件packages/core/scope/src/{index,store}.ts
dsh-agent-presets认识 preset 目录、挂常驻挂载、把 agent 认到挂载名下packages/preset/agent-presets/src/
dsh-system-prompt收集四类提示词贡献、排序去重、组装成一次请求的输入packages/core/system-prompt/src/index.ts
Typert + api/gateway从 TypeScript 类型图生成 RPC 契约,落成运行时服务packages/typert/*packages/api/*
host/webserver 等HTTP 路由、静态托管、浏览器 RPC 代理packages/host/*

2.3 主线走一遍(不进代码)

浏览器点「新建会话,用 PTC 模式」→ 宿主 composeAgent() 先把 preset id 解析出来,交回一个 setup 回调 → agent 工厂造出 agent、铸出它的 scope 上下文、在发布前调用这个 setupsetuppresets.mount(agentCtx, 'code'):确保 code 的常驻挂载存在,然后把这个 agent 的钥匙认到挂载的钥匙名下 → 此后这个 agent 每一步组装提示词时,工具注册表和提示词注册表都沿 agent → code → global 这条链取值,于是它看到 Code Mode 的目录,而隔壁 native 会话看到自己的。

依据:packages/host/apiproxy/src/api-proxy.ts:1168composeAgent)、:1245await presets.mount(agentCtx, resolvedId))。


3. scope 原语:一把钥匙,两个方向

这一节讲最底层的机制。它只有 200 行代码,但整章的一切都靠它。

3.1 钥匙就是一个普通对象

ScopeKey 的类型定义是 object——任意一个对象,靠身份(===)比较,不靠名字。

// packages/core/scope/src/index.ts:15
export type ScopeKey = object

createScope(ctx, key) 做两件事:向 Cordis 插一个空插件拿到一根 fiber(生命周期边界),然后把 key 写进这根 fiber 的上下文里(用一个模块私有的 Symbol 标签 kScope),返回这个「带标签的上下文」。

// packages/core/scope/src/index.ts:139-141
const fiber = ctx.plugin(scope)
const scoped: Context = fiber.ctx.extend({ [kScope]: key })

关键在于「谁当钥匙」。 agent 用的是它自己:

// packages/core/agent-loop/src/agent.ts:94
this.scope = createScope(loopCtx, this)

Agent 对象既是主体,又是它自己的作用域钥匙。所以后面 assembleContextFor(agent) 里能直接写 { agent, scope: agent }packages/core/agent/src/dispatch.ts:174)——不需要额外维护一张 id 到 scope 的映射表。

preset 那边则临时造一个字面量当钥匙:const key: ScopeKey = { agentPreset: preset.id }packages/preset/agent-presets/src/index.ts:514)。

3.2 一条父子关系,两个方向的语义

bindScopeParent(key, parent) 把两把钥匙连成父子。这条关系存在一个模块级 WeakMap 里(packages/core/scope/src/index.ts:39),既不在上下文里也不在服务里,所以谁也偷不走。

同一条链,两个方向的用法完全相反——这是整个设计最巧的一点:

注册视图:向下继承 事件路由:向上冒泡
(子看得见祖先的注册) (祖先听得见子孙的事件)

global 层 global 监听器
│ 继承 ▲ 收到
▼ │
preset 层(standard) preset 监听器
│ 继承 ▲ 收到
▼ │
agent 层(会话 A) 会话 A 发出的事件
  • 向下scopeChainOf(key) 返回 [自己, 父, 祖父, …],取注册时按这条链从远到近叠加,近的覆盖远的(packages/core/scope/src/store.ts:192 chainLayers / :208 merge)。
  • 向上scopeTarget(base, key) 造出一个「路由载体」,它的过滤函数看监听器上下文的标签,只要这个标签等于分发钥匙本身或它的任一祖先,就放行packages/core/scope/src/index.ts:177-180)。反过来不行——比 dispatch 钥匙更低的标签一律排除。

一句话记住:注册往下流,事件往上冒。 这就是为什么「一份常驻挂载能观察到挂在它下面的每一个 agent」,而隔壁 preset 的监听器一声也听不到。

再看几个细节,都是防误用的:

保护怎么做在哪
不能成环绑定前从 parent 往上走一遍,撞见 key 就抛index.ts:54-59 linkScopeParent
不能被外人改父已有父的 key 再绑直接抛;只有原绑定者拿到的 ScopeParentBinding.rebind 能改index.ts:72-82
载体不泄露主体Scoped<T> 是个只有 filter 的空对象,真主体只走事件参数index.ts:27:170

3.3 分层存储:ScopedLayers

注册表要支持「按 scope 分层」,就都用同一个容器 ScopedLayerspackages/core/scope/src/store.ts:159)。它管两种层:一个饿汉式建好的 global,和一张 Map<ScopeKey, Layer> 的按需 scoped 层。

它对外只有四个动作,值得逐个点破:

方法语义为什么这么设计
peek(scope)只看本层,故意不看链问「这个 scope 自己声明了什么限制」时,绝不能捡到祖先的
chainLayers(scope)链上已存在的层,最远的在前调用方顺序叠加,最近的 scope 有最终发言权
merge(scope, pick)global 命名表 + 链上影子,同名后者覆盖「影子(shadow)」就在这一行发生
effect(ctx, action, opts)注册即副作用:从 ctx 读 scope、写层、返回 Cordis disposer插件卸载自动回收;空层顺手删掉

effect 里有个容易忽略的正确性细节:如果 action 抛了,而这一层是这次调用刚创建的且仍为空,就把它从 Map 里删掉,不留垃圾(store.ts:253)。

3.4 演示:分层查找是怎么回事

// 示意,非源码:把三层注册合并成一个 agent 眼里的有效视图
const global = new Map([['bash', bashTool], ['web_search', webTool]])
const preset = new Map([['run_code', runCodeTool]]) // code preset 这一层
const agent = new Map() // 这个会话自己没加东西

// 从最远的祖先往最近叠,同名后者赢
const effective = new Map([...global, ...preset, ...agent])
// → bash, web_search, run_code

重点看:agent 层通常是空的。真正装东西的是 preset 层,agent 只是通过认父把它「看见」。


4. agent presets:每套能力只装一份,会话去认领

4.1 一个 preset 就是一个目录

文件必需内容
agent.cordis.yml一份 Cordis 插件行列表——这个 preset 要挂哪些插件
preset.yml只有展示文字:name / description / order

目录名就是 preset id,且必须匹配 ^[a-z0-9][a-z0-9-]*$packages/preset/agent-presets/src/preset.ts:18 PRESET_ID)。

这不是风格规定,是containment(越界防护):id 会变成路径片段,..、分隔符、绝对路径样式的名字都会把目录指到部署授权的根之外。

展示文字为什么单独一个文件?因为组装文件是一个顶层列表,YAML 语法上没法在旁边挂兄弟键;伪造一行 metadata 又会被 Loader 当插件去加载(metadata.ts:1-12)。而且 idtrust 故意不可写——否则本地写的 preset 能自称是随产品出厂的那份。

4.2 发现:不缓存,且带健康诊断

list() / resolve() 每次调用重扫根目录,不做记忆化。理由很实在:进程跑着的时候新写的 preset 要立刻可见,删掉的要立刻从选择器里消失(index.ts:78-81)。

扫描时对每个目录判「健康」,坏了也照样列出来,只是带上 broken 原因:

目录名不合法(.DS_Store 之类) → 直接跳过(它占不住任何 id)
目录名合法
├─ 没有 agent.cordis.yml → broken: 文件缺失,目录仍占着 id
├─ YAML 解析失败 → broken: 报第一行错误
├─ 不是插件行列表 → broken: 指出第几行不对
└─ 一切正常 → 正常行

为什么坏的不隐藏?因为隐藏了那个目录还占着 id——复制时会拒绝这个名字,而界面上一个可删的东西都看不到(discovery.ts:8-13)。

一个诚实的细节:健康检查解析 YAML 时用的是 Loader 自己的方言entryListSchema,带 !!js),所以它永远不会把 Loader 能接受的组装误判为坏的(discovery.ts:86-97)。

4.3 常驻挂载:每个 preset 只挂一次

这是本章最重要的设计决策。

朴素做法是每个会话把 preset 里的插件装一遍。DeepSeek Harness 不这么做,它每个 preset 只挂一次:插件实例、工具注册、提示词分节、投影单元,全进程各有且只有一份

为什么可以?因为这些插件都早于 preset 存在,它们内部本来就按 Session/Agent 分键存状态(index.ts:6-9)。共享一个实例,会话之间照样互不干扰。

好处也很直接:冷读转录(一个已经结束的会话,没有活 agent)也能解析到同一批注册——standingKeyFor(id) 保证挂载存在但不启动任何 agent、session、turn(index.ts:485)。宿主渲染历史工具卡片就靠这个(api-proxy.ts:1537 presenterScopeFor:1609)。

第一个会话说「我要 standard」

├─► standing Map 里没有 → 造钥匙 {agentPreset:'standard'}
│ → createScope(selfCtx, key)
│ → 记下组装文件的 stamp(mtime + size)
│ → mountPreset(scope.ctx, preset) ← 真正装插件
│ → 存进 Map(存的是 Promise:单飞)
└─► 认父:bindScopeParent(agentKey, standingKey)

第二个会话说「我要 standard」
└─► Map 里有 → 比 stamp
├─ 一样 → 直接认父(不装任何东西)
└─ 变了 → 丢掉这一代,装下一代;已加入的会话仍留在旧代

三个细节都在 ensureStandingindex.ts:491-534)里:

  • 单飞(single-flight):Map 存的是 Promise<StandingMount>,两个会话同时首次用一个 preset,共享同一次组装。
  • 失败即剔除:settled 失败的会从 Map 删掉,等文件修好后下一个会话重试。
  • 文件变更 = 换代:文件是唯一的组装编辑器(授权只有复制/删除),所以 stamp 就是感知编辑的手段。已加入的会话永远留在自己那一代——被取代的那一代在进程活着时不销毁(源码里标了 TODO,说明它等的是「加入计数归零」)。
  • stamp 先取再读文件:一次和挂载竞争的编辑会让 stamp 显得过期而不是悄悄「当前」,下个会话就会刷新(index.ts:518-521)。

还有一个只有踩过才知道的坑,写在 selfCtx 上(index.ts:120-128):常驻挂载必须挂在服务自己那个未被追踪的原始 ctx 上。因为方法通过可追踪代理调用时 this.ctx 会被重绑到调用方的上下文,从它铸出的子树会经调用方的 shadow fiber 解析服务,preset 里的行就会在自己声明的服务上失败。

4.4 agent 怎么「加入」:四个动作

动作同步/异步干什么用在哪
mount(agentCtx, id?)async确保常驻挂载 + 首次认父,返回 preset 供调用方记录根会话创建
composeFrom(agentCtx, parentCtx)sync认到父 agent 已在跑的那一代,不读名册、不挂任何东西子 agent(subagent)
recompose(agentCtx, id)async改父链(不是卸载重装),换一套组装空白会话切 preset
standingKeyFor(id?)async只要钥匙,不要 agent冷读转录渲染

composeFrom 的两个设计理由都很硬(index.ts:290-315):

  • 必须是「绑」不是「挂」:按 id 重新解析会拿到另一代——父会话启动后组装文件被编辑过的话,子 agent 的能力就和父的历史对不上了;父的 preset 被删掉的话,子直接起不来而父还在跑。
  • 必须同步:两个进程内 subagent 驱动都在同步的 setup 窗口里造子 agent,异步的挂载在那里根本没法用。

recompose改父链,不是卸载index.ts:437-472):常驻挂载是共享且永久的,旧的还要给别的会话用;新的在链移动之前就确保好,所以未知或坏掉的 preset 抛错时 agent 原封不动,没有半拆状态要恢复。改链走的是这个服务在 mount 时私藏的那个 ScopeParentBinding——dsh-scope 里唯一的改链权限。

注意:仓库 README 里那句「recompose() unmounts the installed subtree and mounts the new one」与源码不符;以源码为准(packages/preset/agent-presets/src/index.ts:445-452 明确写着 "The swap is a parent re-link, not an unmount")。

「只有空白会话能换」是产品规则,不是机制限制——机制上随时能改链,但换掉工具会让日志里已有的工具调用变成新组装做不到的动作。这条检查由调用方负责,服务本身不读会话历史。网关在线路层挡(返回 agent-preset-locked)。

4.5 两条铁律

铁律一:preset 里发布服务的行必须待在带 isolate realm 的组里

没有 realm 的 provider 会把实现存进根 realm 的 symbol 下,那是进程全局的:第二个会话挂同一个 preset 就和第一个撞车,宿主读取时会为每个会话解析到同一个实例。

mountPreset 在挂载结束时就地检查并拒绝:

// packages/preset/agent-presets/src/mount.ts:361-367(节选)
const leaked = leakedServices(agentCtx, fiber)
if (leaked.length > 0) {
throw new Error(`row(s) published process-global service(s) [${leaked.join(', ')}]; `
+ 'a preset service must sit behind an `isolate` realm or move to the host composition')
}

leakedServicesmount.ts:189)的判定很干净:遍历服务存储的所有 own symbol,找出实现 fiber 属于这棵子树的,再看根 realm 的同名槽是不是正指向这个 symbol——是就是泄漏。带 realm 的 provider 存在 realm 私有 symbol 下,自然不在名单里。

standard 的组装文件把这条规则写在开头,还纠正了一个常见误解:isolate: true条目本地 realm(这份挂载自己的私有实例);共享标签并不会池化实例——provide() 在同一个 realm symbol 下第二次注册会抛,标签是用来加入 realm 的(apps/cli/config/agent-presets/standard/agent.cordis.yml:11-18)。

那么哪些东西必须留在宿主平面?组装文件里逐条注了原因,规律是一条:

凡是有宿主行 inject 它的服务,就必须留在宿主平面——注入在任何会话存在之前就解析完了,没有 agent 可以当键。

留在宿主的东西注释里给的理由
shell-envapps/cli/src/web.ts 注入它来发布 DSH_WEB_URL;藏在 realm 后这些变量根本到不了模型的 shellstandard/agent.cordis.yml:37-43
后台任务注册表生产者在任何 realm 之外(tool-bashctx.get 拿),条目本地 realm 对兄弟行不可见:66-72
goals 服务网关把 goal 域当 Remote 端点服务,接收者来自生成的描述符,在宿主解析:91-96
tokenMeter它按 Session 分键、拥有浏览器读取的上下文计量投影单元;藏在 realm 后这些单元会随挂载来去:131-136
subagents 注册表进程单例,其跨会话查询由 api-proxy 服务给浏览器;provider 名只能注册一次:159-163
tool-subagent-report它在单例上注册的是可续设置而非本 agent 调的工具,而设置列表不认 scope——每个挂载一份意味着第二次就抛:169-173

反过来,天生就该按 agent 隔离的(比如 plan 模式的状态)用 realm 就不是权宜之计,而是正确的生命周期(:102-108)。

那 preset 到底能决定什么?能不能用。工具注册表在宿主,但「这个 agent 的目录里有没有 goal 工具」由 preset 里那一行说了算。

铁律二:唯一合法的挂载点是 agent 工厂的 setup(agentCtx)

因为只有在那里,加入动作是在 agent 尚未发布时完成的——组装被拒绝,整个 agent 创建会回滚,绝不会留下一个半组装的会话(index.ts:16-20:268-273)。

这个服务对「没加入任何 preset 就发布」的 agent 只警告不阻断index.ts:166-174)。理由写得很清楚:同步的 agent/created 监听器抛错会否决发布,而在名册之外组装 agent 是合法的——ACP、SDK server、headless 三个入口都这么造 agent。

4.6 codestandard 只差一行

这是「配置即产品」最漂亮的例证。两个文件 diff 之后,去掉注释差异,真正的新增只有末尾这一行:

# apps/cli/config/agent-presets/code/agent.cordis.yml:257-262
- id: tool-presentation
name: '@deepseek-ai/dsh-agent-tool-presentation'
config:
mode: code

它做的事是 ctx.tools.presentAs('code')packages/core/tools/src/index.ts:946),只为挂载它的这个 scope 声明呈现方式:模型不再一次一个工具调用,而是写一段 TypeScript 程序打给生成的 SDK,由 run_code 执行——五个来回压缩成一个。原生会话就在同一个进程里跑在旁边,各看各的目录。详见第 4 章

注册表本身留在宿主平面(它的消费者——循环调度器、API 代理的呈现器——都在宿主),preset 拥有的只是这份注册表对这一个 agent 的呈现packages/core/agent-tool-presentation/README.md)。

minimal 走的是另一个极端:人格设成 complete: true(整份系统提示词就这一段,别人加不进来)、includeRuntimeContext: false(不要运行时快照)、两个 isolate 组各自私有一套 terminal 和 fs,一共只有两个工具(apps/cli/config/agent-presets/minimal/agent.cordis.yml)。

4.7 一个会话到底跑在哪个 preset 上

两个答案,别搞混:

问题答案来源用在哪
会话创建时选了什么session.header.agentPreset(深冻结,创建事实)只是起点
会话实际跑的是什么resolveSessionPreset(session):倒着扫日志找最后一条 agent-preset/selected,没有才回落到 header恢复、fork、选择器摘要
// packages/preset/agent-presets/src/session.ts:48-54
export function resolveSessionPreset(session: PresetBearingSession): string | undefined {
for (let index = session.events.length - 1; index >= 0; index -= 1) {
const event = session.events[index]
if (event?.type === 'agent-preset/selected') return event.data.agentPreset
}
return session.header.agentPreset
}

为什么切换必须写进日志?这是仓库那条 「模型可见 ⟺ 已记录」 规则的直接后果(见第 2 章):preset 决定了模型看到的工具 schema 和提示词分节,所以它必须能从日志里重建。只读 header 会把一个切换过的会话按创建时的组装重建,回放新工具集做不到的历史——恰恰是空白期限制要防的那个事故。


5. 系统提示词的组装

5.1 四类贡献

SystemPrompt 是个注册表服务,四个注册方法各自解决一件事:

方法贡献什么去向源码
section(s)一段有序的系统提示词正文拼成 system 消息index.ts:381
context(c)动态运行时上下文变成 user 角色的快照消息进历史index.ts:398
variable(n, f){{name}} 的取值提供者渲染时严格插值index.ts:446
tools(f)本次组装可见的工具 schema请求里的 tools 数组index.ts:430

四个方法都走同一个 layers.effect(this.ctx, …)——注册落在调用方上下文的 scope 层。这就是 preset 里的一行提示词能只影响一个会话的全部原因。

配套还有 suppressRuntimeContext()index.ts:415),按 scope 掐掉全部动态上下文,且不动那些拥有/执行这些事实的服务。

5.2 谁在贡献

贡献者类型内容位置
注册表自己sectionharness:identity(order -100)、deployment:persona(order 0index.ts:357-369
dsh-personasectionpreset 自己的人格,同名影子掉部署人格packages/preset/persona/src/index.ts:60-67
dsh-toolstools + section工具 schema;非 native 时再加 SDK 与折叠说明packages/core/tools/src/index.ts:832-835
dsh-agent-loopvariableprovider / model / cwdpackages/core/agent-loop/src/index.ts:351-353
dsh-sandbox-policycontext当前沙箱策略(order 110)packages/sandbox/sandbox-policy/src/index.ts:113
dsh-time-context直接注 user 消息采样时间 + 浏览器时区 + 距上次的间隔packages/context/time-context/src/index.tsagent/pre-step
各工具插件section自己的使用指引,order 100–199packages/shell/tool-bash/src/index.ts:236

dsh-persona 这个包为什么必须存在,注释说得很直白:preset 挂不了提示词注册表本身,没有这一行的话,preset 能换 agent 的工具,却永远换不了它的身份(persona/src/index.ts:9-12)。它也是只能 scope 用的——全局挂就会和注册表自己那次注册撞名字,当场失败。

dsh-agent-instructionsAGENTS.md 加载器)走的是另一条路:它不注册 prompt context,而是在 agent/pre-step 里把基线指令作为 user 消息送进 inbox(packages/context/agent-instructions/src/index.ts:80)。同样服从「模型可见 ⟺ 已记录」。

5.3 组装:排序、影子、去重

assemble(context)index.ts:467-542)按固定顺序做六件事:

① 取链上的层 chainLayers(scope) 最远祖先在前
② 变量:global → 链 近的 scope 覆盖同名(index.ts:474-482)
③ sections / contexts:merge(scope, …) 同名影子(:484-485)
④ tools:global + 链上所有 provider 逐个调用,参数 structuredClone 脱钩(:487-503)
⑤ sections 按 order 升序稳定排序;tools 按 toolOrder 或字典序(:504、orderTools:164)
⑥ 跑 waterfall 'system-prompt/assemble',用 scopeTarget(this, scope) 分发(:532)
└─ 事后强制恢复 complete section 和 runtime-context 抑制(:536-541)

几个值得记的点:

  • section 名是去重键。 常量 PERSONA_SECTION = 'deployment:persona' 被导出,就是为了让 preset 的人格和注册表的默认人格填同一个槽——影子而不是并排出现(index.ts:122-131)。dsh-persona 特意 import 这个常量而不是自己抄一份字符串。
  • complete 是硬保证。 声明了 complete: true 的分节,会在 waterfall 之后被恢复为唯一分节。也就是说 waterfall 监听器还能改工具、上下文、变量,但加不进也换不掉这个 scope 的系统提示词(index.ts:24-27:536-541)。同时生效两个 complete 分节直接抛错。
  • 工具顺序对 KV cache 敏感。 名字比较用字典序(code-unit)而非 locale 比较,就是为了每台机器上顺序完全一致compareToolNamesindex.ts:181)。
  • 插值是严格的。 变量名必须匹配 ^[a-z][a-z0-9_]*$;未注册、值为 undefined、写法畸形都抛错;而孤零零一个 {{ 后面再没有 }} 被当成普通散文放过(interpolateindex.ts:258-295)。
  • 不走原型链。Object.hasOwn(variables, name) 而不是 in,防止 {{constructor}} 这种名字解析到 Object.prototypeindex.ts:283)。

5.4 运行时上下文快照怎么进日志

分节进 system 消息,上下文进 user 消息。 后者要落成会话事件——否则就违反「模型可见 ⟺ 已记录」。

每个 step 开始(agent-loop preStep)

├─ assemble(assembleContextFor(agent, signal)) ← scope = agent 自己

├─ renderContextSections(assembly) → 逐段插值,丢掉空的
├─ joinContextSections(sections) → 加一句「本快照取代此前的运行时快照」

└─ runtimeContext.project(joined, sections)
├─ 和已保留的那份**一字不差** → 返回 undefined(不发消息)
├─ 变了 → 造一条 user 消息(source.form = 'snapshot')
└─ 变空且曾经有过 → 造一条「运行时上下文:无」的清除标记

依据:packages/core/agent-loop/src/agent.ts:230-238packages/core/agent-loop/src/runtime-context.ts:64-75

RuntimeContextProjectionruntime-context.ts:25)的状态只有三种,注释写得很精确:undefined = 从来没有过快照;null = 有过但不再保留;对象 = 当前保留的那条。它的初始状态从会话事件倒着重建,之后跟着权威的 session/event 流走——自己不拥有提交点,只跟踪。这就是「先提交、后发布派生状态」那条规矩。


6. 产品外壳:一个内核,四种前端

6.1 全景

怎么读:中间那一竖是同一套内核,左右是四种把它包出去的方式。

Web 浏览器 CLI 一次性 语言 SDK 编辑器/自动化
(ui-* 插件) (headless) (TS / Python) (ACP 客户端)
│ │ │ │
HTTP + WS 直接同进程 stdio JSON-RPC stdio JSON-RPC
│ │ │ (Agent Client
▼ ▼ ▼ Protocol)
webserver / headless-runner sdk-jsonrpc-server │
connection / (一次任务, (每 sessionId ▼
gateway / apiproxy 打印末条回复) 一个 agent) dsh-acp 插件
│ │ │ │
└───────────────────┴──────┬───────────┴───────────────────┘

ctx.agents / ctx.tools / ctx.systemPrompt
——同一棵 Cordis 树,同一份内核

四种外壳的入口都在仓库里:

外壳启动方式入口代码
Webdsh web → profile webdsh-base + dsh-web-appapps/cli/src/{bin,profile-boot}.ts
CLI 一次性dsh --profile headless "task"packages/bundle/headless/src/{index,startup}.ts
SDK 服务端组装里挂 jsonrpc 插件,走 stdiopackages/sdk/{protocol,server,client}python/
ACP 自动化dsh-acp 插件占住 stdin/stdoutpackages/acp/acpexamples/acp-agent

6.2 Typert:从 TypeScript 类型图长出 RPC 契约

浏览器要调宿主的方法。手写一份 wire 协议再手写两边的校验,是漂移的经典来源。Typert 的做法是把 TypeScript 源码里的类型图当唯一事实源,生成两边的东西。

它拆成四个包,职责界线很干净(packages/typert/README.md):

干什么Cordis 键
generator构建期:把源码类型树转成与编译器无关的 FaceModel / TypeGraph,再渲染成产物无(库)
registry运行期:存放生成的反射与活的 Zod schemactx.typert
loader在 Loader 组装里发现条目、注册生成的宿主产物消费 ctx.loader / ctx.typert
protocol@Remote / @RemoteScope 标注

两个关键设计:

  • 发射器只吃 model,永远拿不到 TypeScript AST 或 checker 对象——静态分析可以完全脱离 Cordis 消费这份 model。
  • 发布是包级 opt-in:业务包必须自己暴露 package/typert(宿主)和 package/client/typert(客户端)入口;生成的声明把 TYPERT 暴露为 unknown,所以业务包不依赖运行时注册表

6.3 网关与 BFF:契约怎么落到运行时

api/gateway两侧同一份契约的 RPC 端点(packages/api/gateway/README.md):

  • 宿主侧 ctx.typertGateway.invoke():解析描述符 → 校验精确命名实参 → 解析登记的对象/Context 身份 → 调公开业务方法 → 校验返回值。
  • 客户端侧 ctx.remote.$mount():校验并注册生成的贡献,然后为调用方的 Cordis fiber 装上具体方法。每个命名空间是一个被追踪的子服务,最后一个方法撤销后自动卸载。

有两个细节值得记:

  • signal: AbortSignal 是描述符元数据,不是 wire 实参。 Connection 把 signal 交给网关,网关在解码后的业务参数之后注入它。
  • 方法查找和调用用普通对象和函数,不用 JavaScript Proxy(README 明说)。

api/remotes 是 BFF(Backend for Frontend)层,宿主侧拥有 Agent/Session 身份策略,客户端侧把生成的 /remote 产物当运行时值 import 进来挂上。它的转发事件白名单 API_REMOTE_FORWARDED_EVENTS 是一个数组,「多转发一个事件」就是往数组里加一项——类型投影、消费者键面、宿主转发循环全都从它派生

它也是全仓库唯一故意跨两个 TypeScript face 的包:宿主入口要进宿主 Typert 图,而 src/client/index.ts 在宿主 tsdown 生成 /remote 声明之前根本编译不了。

6.4 Web 服务器与静态托管

host/webserver 是一个完全不认识 harness 概念node:http 插件:

能力语义
register(route)具名 exact / prefix HTTP 路由,同表内重名直接抛
registerUpgrade(route)精确路径的升级路由;未匹配的升级连接直接关掉
registerFallback(handler)只有一个座位,第二次注册抛错;没人占时返回 404
tapIndex(transform)index.html 变换,按注册顺序跑

匹配顺序是固定的:先整表 exact,再最长前缀,最后 fallback。注册顺序不带任何请求语义。

host/frontend-static 就是来占那个唯一 fallback 座位的:服务构建好的前端目录,越界 403,未命中一律回落 index.html 且状态码 200(SPA 路由),未知扩展名发 application/octet-stream。每个 index 响应都会跑一遍 applyIndexTaps——boot manifest 就是这么到页面上的

distIndex组装事实:由 dsh-web-app 通过前端包的 exports 解析出来再挂上,部署方永远不硬编码它。

6.5 客户端本身也是插件

Web 前端不是一个整体 bundle,而是同样一棵 Cordis 树,只不过跑在浏览器里

client/modules 是「Node 内部 ESM loader 的浏览器对等物」,做成一张惰性 CJS 表。Web shell 挂上 vendored 的 Cordis Loader 管条目治理(fiber 生命周期、inject 等待、更新/刷新),再把 ClientModuleLoader 注入它的 internal 契约——vendored 那侧唯一的消费点就是 EntryTree.import,所以替换 internal 恰好只替换了「插件代码怎么到达」这一件事。

惰性模型(web2)值得单独点出:执行一个插件 bundle 只是注册它的工厂window.__ModuleLoader__.load({id, factory}));所有模块体副作用——包括 CSS 注入——都在工厂闭包里,等到物化(factory(require))才跑,不是脚本执行时。所以加载顺序不需要外部编排;require 成环会抛(工厂形式的 CJS 交不出部分导出)。

再往上是三十来个 ui-* 插件,各占一个域:

职责
运行时client/runtimeReact-free 对象服务:SlotRegistry、SessionRuntime、WorkspaceRuntime;把宿主流扇出给 Session/Workspace 主人
会话视图client/ui-conversation骨架、聊天视图、composer、详情壳、ConversationController
工具呈现client/ui-tool按 wire 工具名分发到 tool.call.toolview 槽;未注册的用通用卡片
表单client/schema-formsettings.describe 里序列化的 schemastery schema 反序列化回活的校验器
preset 选择器client/ui-agent-preset名册、切换、复制/删除

两条边界很清楚:

  • ui-tool 只做呈现。调用/结果配对、生命周期、递归 subCalls 投影权威在 Runtime;ChatFlow 位置权威在会话视图。业务 UI 包只注册自己的 wire 工具名和原子视图。
  • schema-form 让浏览器端校验永不漂移rehydrateSchemanew Schema(json) 造回来的,就是宿主上校验同一节配置的那个 schema 对象。「字段被覆盖」判定用的是是否存在这个键hasPath),不是值比较——一个和默认值相同的覆盖仍然是覆盖。

6.6 另外三种外壳

CLI 一次性(headless)cordis.patch.yml 直接骑在 dsh-base 上,给编码人格和工具模式、关掉 HMR、把 Code Mode 的 worker 挂成核心执行能力、插入 headless-runner。Loader 稳定后,runner 造一个全新的持久 agent、把任务当普通 user 消息提交、等静默、刷新 Session、把最后一条非空 assistant 文本写到 stdout,然后通过 launcher 提供的 ctx.appExit 请求退出(末条 turn/end completed → 0,否则 1)。进程不开任何监听端口。

JSON-RPC SDKsdk/protocol 是纯库(无插件、无 Config、无注册),提供一个换行分隔的 JSON-RPC 2.0 传输类和两端共用的类型。sdk/serverjsonrpc 插件 inject: ['agents']sessionId 取或建一个 agent。客户端有两个:TypeScript 的 sdk/clientDeepSeekHarness 是高层拥有式 API,HarnessClient 是低层协议客户端;启动规格必须显式给 command/args)和 Python 的 deepseek-harness(自带运行时二进制,能自己找到打包的可执行文件)。

ACPpackages/acp/acp 在 stdin/stdout 上开一个 AgentSideConnection,驱动 ctx.agentsstdout 保留给协议帧。README 把它的定位说得毫不含糊——它是传输适配器,不是 UI 集成,也不是能力接缝:不提供编辑器导航、转录回放、命令、模式、配置选择器、征询、推理、计划、标题或工具呈现。这些属于 Web 宿主和客户端模块。

6.7 启动器:profile 与补丁层叠

四种外壳最终都从同一个启动器进来。dshbin.ts 按模式动态 import,只有 profile / plugin / dump-config 三条路(apps/cli/src/bin.ts:29)。

profile 这条路的核心是 composeProfileapps/cli/src/profile-boot.ts:142),它把补丁层按固定顺序叠起来:

bundle 层(package.json 的 dsh.profile.bundles 顺序)
→ profile 自己的 cordis.patch.yml
→ $DSH_HOME/cordis.patch.yml(机器级偏好,压过每 profile 层)
→ --patch 覆盖层
→ 出厂 preset 根 + 遥测开关

出厂 preset 根是在这里补进去的——它在安装好的 app 自己的 config 旁边,只有这个 app 解析得了(profile-boot.ts:35:159-167)。可写的那个根是 dsh-agent-presets 自己的默认值(includeUserRoot),所以一个从没走到这个补丁的启动器,照样能找到用户自己写的 preset

profile 根配置文件每次都被重写成空列表,这不是偷懒:整套组装都是补丁层,而 vendored Loader 的树回写(插件自毁就会持久化当前树)会把组装出来的行烤进这个文件,下次启动每个 bundle 插入就会翻倍(profile-boot.ts:86-96)。同样的理由,PresetTree 直接把 write() 覆盖成空方法——否则一个会话结束就会把出厂组装截断成 []packages/preset/agent-presets/src/mount.ts:110-111)。

dsh plugin 是个薄薄的 pnpm 转发器:在 profile 目录里跑 pnpm <args>,然后按安装后的实际状态(不是依赖 diff)对账 dsh.profile.bundles——所以 update 能激活一个在新版本里才声明 dsh.bundle 的包(apps/cli/src/plugin.ts:120)。


7. 巧妙之处(可以带走的技术)

① 一条父子关系,两个方向。 大多数系统会为「继承」和「事件冒泡」各建一套结构。这里两者共用同一个 WeakMap,只是遍历方向不同:注册视图沿链向下继承,事件准入沿链向上扩展。代码量减半,且两者永远不可能不一致packages/core/scope/src/index.ts:32-39)。

② 主体自己当钥匙。 Agent 对象既是事件主体又是 scope key(agent-loop/src/agent.ts:94),省掉一整张映射表,也让 { agent, scope: agent } 这种写法天然不会漏配(agent/src/dispatch.ts:174)。

③ 权限用「返回值」表达,不用「谁能调用」表达。 bindScopeParent 的改链能力不是一个公开函数,而是绑定时返回ScopeParentBinding。谁先绑谁独占;preset 服务把它私藏在 WeakMap 里,于是「谁能把一个会话换到另一个 preset」这件事有唯一权威(index.ts:41-51agent-presets/src/index.ts:260)。

④ 用观察而非钩子回收记录。 一棵 preset 子树可能被它的 agent 拆掉、被失败的挂载拆掉、被整树卸载拆掉——三条路都会把 fiber.uid 清成 null,那就读的时候顺手扫一遍,而不是给三条路各挂一个钩子。回收放在「每个会话都会走」的挂载路径上,把集合规模压在一代死记录而不是每个会话一条(mount.ts:126-146)。

⑤ 泄漏检测是把已有关系反过来读。 leakedServices(这棵子树发布进根 realm 的服务名)和 serviceForAgent(这棵子树发布在任何地方的那个实例)读的是同一个所有权关系,只是取反(mount.ts:246-248)。fiber 成员判定用对象身份而不是 uid——uid每注册表的计数器,两个不同根里的 fiber 会撞号(mount.ts:158-166)。

⑥ 单飞 + 文件戳 = 廉价热更新。 编辑组装文件的代价被压在「组装改了几次」,而不是「起了多少会话」;已加入的会话继续用自己那一代,语义上和「文件消失了正在跑的会话也不该崩」是一致的(index.ts:241-251)。

⑦ 复制是唯一的授权写。 新 preset 只能是既有目录的整体拷贝——组装文本永远不跨这道缝:调用方只给两个 id 和一个显示名。所以拷贝出来的东西「和源一样可加载」,而且授权本身没有给出名册原本没有的任何能力index.ts:365-378)。

⑧ 严格插值里给「散文」留了路。 {{ 后面再没有 }} 就当普通文字,有才判畸形(system-prompt/src/index.ts:268-276)。既保住了「未知变量必须炸」,又不会因为提示词里写了两个花括号就崩。


8. 边界与局限

诚实地按源码和配置注释说:

① developer preview,会破坏兼容。 README 第 9-11 行原文标着 developer preview 并全大写写明「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」。

② 会话磁盘格式没有兼容承诺。 SESSION_FORMAT_VERSION 钉在 0,注释直说:未发布期间不隐含任何兼容性,不兼容的日志直接拒绝,不提供迁移packages/core/session/src/types.ts:36-56)。持久化后端在 load 时对任何其它版本号抛错(packages/core/session/src/index.ts:101-102)。

③ 被取代的 preset 代次不回收。 源码里明确留了 TODO:子树不是惰性的(dsh-skill-filesystem 在监视它的根),而设置页的授权流会把「组装变了」变成每次保存一个事件;要回收得先给 StandingMount 加一个已加入 agent 计数(agent-presets/src/index.ts:502-507)。

④ 未加入 preset 只警告不阻断。 而且注释自己承认一个已知误报:先裸建、后由 recompose 绑定的会话会在首次绑定前被警告一次。今天没有出货流程这么做,但机制上留着(index.ts:162-165)。

⑤ 换 preset 的「空白会话」检查在调用方。 服务本身不读会话历史(index.ts:441-443);线路层由网关挡。也就是说,直接调服务的代码可以绕过这条产品规则

⑥ Windows 沙箱只能部分强制。 受限令牌为了进程初始化必须保留 Everyone,所以外部对象上授予 Everyone 写权限的仍可写;NTFS 硬链接还会让同一个文件对象在工作区内外互为别名。provider 如实报 enforcement: 'partial' 而不是把这个边界说成完全强制(packages/sandbox/sandbox-local/README.md:38)。同一份文档还记了两个更硬的限制:受限令牌下控制台隔离不可用,以及受限进程无法用管道捕获孙进程输出(命名管道的默认安全描述符模板不给写权限),所以「必须捕获输出的工具不能在受限下运行」。

⑦ Web 服务器没有 TLS、认证或来源策略。 绑非回环地址就是把服务暴露给那个网络;部署加固被刻意放在 dev-facing v1 的范围之外(packages/host/webserver/README.md)。

⑧ 客户端能力集是构建期定死的。 api-remotes 的客户端不在运行时发现宿主的活服务或 Remote 定义;加一个能力就得加一次显式的 /remote 值 import 和挂载。转发事件也是原样送达——没有投影、没有脱敏、没有按 scope 订阅、重连后不重放packages/api/remotes/README.mdpackages/api/gateway/README.md)。

⑨ ACP 是自动化专用。 它明说自己不做编辑器导航、转录回放、命令、模式、配置选择器、征询、推理、计划、标题、工具呈现。

⑩ headless 只跑一个任务。 没有交互后续界面;它等 agent 完成全部工作回到 idle,然后打印这段区间里最后一条非空 assistant 消息。而且 ctx.appExit 是 launcher 拥有的——在 dsh 启动器之外 boot headless profile,会在激活时直接失败


9. 代码地图(导航索引)

主题文件路径符号名
铸作用域上下文packages/core/scope/src/index.tscreateScopescopeOfkScope
父子链(绑定/读/走链)packages/core/scope/src/index.tsbindScopeParentlinkScopeParentscopeParentOfscopeChainOfScopeParentBinding
事件路由载体packages/core/scope/src/index.tsscopeTargetScopedisScopeCarriercarrierKeyOf
分层存储与合并packages/core/scope/src/store.tsScopedLayerschainLayersmergepeekeffectNamedEntriesAnonymousEntries
被 scope 过滤的事件清单packages/core/scope/src/scoped-events.generated.tsscopedSubjectResolverFor
preset 名册服务packages/preset/agent-presets/src/index.tsAgentPresetsmountcomposeFromrecomposestandingKeyForensureStandingcomposedPresetserviceFor
常驻挂载与文件戳packages/preset/agent-presets/src/index.tsStandingMountcompositionStampsameStamp
挂载与守卫packages/preset/agent-presets/src/mount.tsmountPresetPresetTreeleakedServicesinactiveRowsstandingMountForserviceForAgentwithinFiber
目录发现与健康packages/preset/agent-presets/src/discovery.tsscanRootdiscoverPresetscompositionProblementryListProblemCOMPOSITION_FILEUSER_PRESET_DIR
preset 词汇与错误packages/preset/agent-presets/src/preset.tsAgentPresetPRESET_IDPresetRootUnknownPresetErrorPresetMountError
展示元数据packages/preset/agent-presets/src/metadata.tsMETADATA_FILEreadPresetMetadataPresetMetadata
会话跑哪个 presetpackages/preset/agent-presets/src/session.tsresolveSessionPresetagent-preset/selected
出厂 preset 实例apps/cli/config/agent-presets/{standard,code,cordis,minimal}/agent.cordis.ymlpreset.yml
提示词注册表packages/core/system-prompt/src/index.tsSystemPromptsectioncontextvariabletoolssuppressRuntimeContextassemble
渲染与插值packages/core/system-prompt/src/index.tsrenderPromptrenderContextSectionsjoinContextSectionsinterpolateorderToolsPERSONA_SECTIONTOOL_ORDER_REST
preset 人格行packages/preset/persona/src/index.tsapplyConfig.completeConfig.includeRuntimeContext
运行时上下文投影packages/core/agent-loop/src/runtime-context.tsRuntimeContextProjectionprojectCLEARED
组装入口(每 step)packages/core/agent-loop/src/agent.tspreStep
组装上下文构造packages/core/agent/src/dispatch.tsassembleContextForagentCarrieragentEvents
时间上下文packages/context/time-context/src/index.tsapplyagent/pre-stepprepend
工作区指令packages/context/agent-instructions/src/index.tsapplyworkspaceContextMessage
宿主挂 preset 的地方packages/host/apiproxy/src/api-proxy.tscomposeAgentpresenterScopeFor
Typert 生成与注册packages/typert/{generator,registry,loader,protocol}/WorkspaceAnalyzerFaceModelEmitterTypertRegistry@Remote@RemoteScope
RPC 网关packages/api/gateway/TypertGatewayService.invokeClientRemote.$mount$on$dispatch
BFF 装配packages/api/remotes/createApiRemoteAgentResolverAPI_REMOTE_FORWARDED_EVENTS
HTTP 与静态托管packages/host/{webserver,frontend-static,apiproxy,plugin-inventory}/WebServer.registerregisterFallbacktapIndexApiProxyServicePluginInventoryGateway
浏览器模块系统packages/client/modules/ClientModuleLoaderregisterStaticprefetchinvalidate
客户端运行时与 UIpackages/client/{runtime,ui-conversation,ui-tool,schema-form,ui-agent-preset}/SessionRuntimeSlotRegistryToolCallTreerehydrateSchema
CLI 入口与 profile 启动apps/cli/src/{bin,plugin,profile-boot}.tsrunProfilecomposeProfileprepareProfilerunPluginSHIPPED_PRESET_ROOT
一次性外壳packages/bundle/headless/src/headless-runnerheadlessStartup
SDK 外壳packages/sdk/{protocol,server,client}/python/JsonRpcLineTransportHarnessSdkJsonRpcServerDeepSeekHarnessHarnessClient
ACP 外壳packages/acp/acp/applyAgentSideConnection

10. 回到全书

  • 这套 scope 机制的基础(Cordis 插件树、realm、ctx.effect)在第 1 章
  • 「preset 切换必须写进日志」背后的规则在第 2 章
  • 「每 step 组装一次提示词」的时机在第 3 章
  • 工具注册表本体、三段式执行、Code Mode 在第 4 章
  • 为什么工具注册表不能下沉到 preset(服务只有在全部消费者一起下沉时才能下沉)在第 5 章
  • 全书导航在 index