青雲的博客
深入浅出 DeepSeek Harness 第六部:能力接入——Skills、MCP和插件 第 38 章

Agent Preset:给单个会话换一套运行时

Agent Preset机制解剖:preset将model/tools/prompt配置打包为一个可切换的运行时单元,通过standing mount单例加载、scope parentage绑定实现per-session组合切换而无需改变全局composition。涵盖preset定义格式(agent.cordis.yml + preset.yml)、discovery扫描机制、trust分级(system vs user)、PresetTree Include子类的import重写与write-back禁止、isolate realm强制隔离service、compositionStamp文件标记驱动generation轮转、composeFrom子agent继承、recompose blank-session切换、resolveSessionPreset resume恢复。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

你可能以为”给 agent 换一套运行时”意味着改全局配置文件然后重启进程。Harness 的 Agent Preset 机制告诉你:每个 session 可以在创建时选择不同的 model/tools/prompt 组合,blank session 里还能动态切换,subagent 自动继承父 agent 的 preset——全部不需要重启。

这一章解剖 preset 的完整机制:定义格式、文件系统 discovery、trust 分级、standing mount 单例共享、scope parentage 绑定、isolate realm 隔离、generation 轮转、子 agent 继承、blank-session 切换和 resume 恢复。

1. Preset 定义:一个目录 = 一套运行时

一个 preset 是文件系统里一个目录。目录名就是 preset id(受 /^[a-z0-9][a-z0-9-]*$/ 约束),目录内有两个文件:

  • agent.cordis.yml(必需):Cordis EntryList 组合文件,定义该 preset 加载哪些 plugins/tools/sections
  • preset.yml(可选):元数据——namedescriptionorder

AgentPreset 接口是每个 preset 在内存中的表示:

export interface AgentPreset {
  readonly id: string          // 目录名
  readonly trust: PresetTrust  // 'system' | 'user'
  readonly path: string        // agent.cordis.yml 绝对路径
  readonly name?: string       // 来自 preset.yml
  readonly description?: string
  readonly order?: number
  readonly broken?: string     // 为什么不能 mount
}

broken preset 仍在 roster 上(hiding it would leave its directory blocking the id),但 mount 路径在前面就拒绝它。

2. Discovery:Roots、Trust 和健康检查

2.1 Roots 与 Trust 分级

AgentPresets 服务构造时组装 resolvedRoots——config.roots(profile-boot 追加 shipped root 为 trust:'system')+ harness home USER_PRESET_DIRtrust:'user')。first-root-wins:同 id 先出现的 root 赢。system root 在前,shipped preset 覆盖同名 user preset。Trust 还决定 authoring 权限——只有 user root 可写。

2.2 scanRoot 扫描

scanRoot 遍历 root 子目录:目录名匹配 PRESET_ID 才考虑,有 agent.cordis.yml 则做 compositionProblem 浅层 YAML 验证(top-level 必须是 array,每 row 须有 name string,group 递归检查),缺文件标 broken。metadata 读取失败只降级不阻止 mount——“presentation is not a capability”。

排序:有 order 按 order 升序,无 order 按 id 字母序。absent root(ENOENT)返回空数组不 throw——user root 在首次 authoring 前不存在。

3. Standing Mount:单例共享的核心

每个 preset 在进程里只有 一个 standing composition 实例。所有选择同一 preset 的 session 通过 scope parentage 共享它的 plugin 实例、tool 注册、prompt sections。

3.1 mount 入口

async mount(agentCtx: Context, id?: string): Promise<AgentPreset> {
  const agentKey = scopeOf(agentCtx)        // unscoped throw
  const preset = await this.resolveMountable(id)  // broken throw
  const standing = await this.ensureStanding(preset)
  this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key))
  return preset
}

3.2 ensureStanding:单 flight + generation 轮转

standingMap<string, Promise<StandingMount>>。已有 pending → await → 比较 CompositionStampmtimeMs + size)→ 相同复用 → 不同删旧递归重建。新建:createScope(selfCtx, {agentPreset: preset.id}) → stamp 在文件读取 取(racing edit 让 stamp stale 而非 silently current)→ mountPreset(scope.ctx, preset) → 成功记录 StandingMount

旧 generation 不 dispose(joined sessions 还在用),新 session 用新 generation。

3.3 PresetTree 两个关键定制

import 重写:bare specifier 从 harness base 解析(不从 preset 目录)。locally authored preset 在 ~/.dsh/ 下,Node 的 upward node_modules walk 到不了 harness dependencies,bare specifier 会 import 失败。relative specifier 和 cordis: builtins 走原逻辑。

write-back 禁止override write(): void {}。Loader teardown 时会调 write() 把 dying tree 写回文件——对 preset 这意味着截断为 []。preset 是 input 不是 persistence target。

3.4 mount 审计

mount 成功加载 subtree 后:

  1. inactiveRows:enabled 但未激活的 row(等待永远不提供的 service)→ reject
  2. leakedServices:subtree 内 row 发布到 root realm(process-global)的 service → reject
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...'
)

4. isolate realm 强制

agent.cordis.yml 里所有注册 service 的 row 必须在 cordis:group + isolate: {serviceName: true} 里。true = entry-local realm(新 Symbol),standing mount 私有,不与其他 preset 共享。

standard preset 的隔离:planMode: true(plan mode 状态)、compaction: true, toolResultPruner: true(compaction)、workflowEngine: true(workflows)。

不需要 isolate 的 rows:只调 ctx.tools.register()ctx.systemPrompt.section() 的——ScopedLayers 自动 scope 到当前 layer。

invariant companion 持续审计:每次 internal/service 事件触发时重新检查所有 live mount 的 leakedServices——捕获 mount 后通过 timer/async 延迟发布 service 的 case。

5. Scope Parentage:agent 怎么看到 preset 注册

ToolRegistry/SkillRegistry/SystemPrompt 使用 ScopedLayers:global layer + per-scope layers。standing mount 的 plugins 注册到 preset scope layer。bindScopeParent(agentKey, standing.key) 后,agent 读 registry 时看到 global + preset layer 叠加。

bindScopeParent 返回 ScopeParentBinding——dsh-scope 唯一的 re-link 权限。AgentPresets 持有 WeakMap<ScopeKey, ScopeParentBinding>,是唯一能 rebind 的地方,防止外部代码移动 agent scope ancestry。

6. Shipped Presets 巡览

四个内置 preset(apps/cli/config/agent-presets/):

PresetNameOrder特征
standard标准模式1完整 coding agent
codePTC 模式2标准 + Code Mode SDK
minimal极简模式3persistent bash + str_replace_editor, persona complete:true
cordisCordis 模式-Cordis 插件开发

standard 的 rows:persona({{model}}/{{cwd}}模板) + agent-instructions、tool-bash/pwsh(!!js 平台条件)、tool-fs/tool-fs-search、tool-jobs、skill-filesystem + tool-skill、tool-goal、planning 组、compaction 组、delegation 组(subagent spawn/fork + workflows + tool-ralph)、tool-ask-user/tool-todo/tool-web。

minimal 的极端选择:persona complete: true 抑制所有其他 sections(system prompt 只有一句话),includeRuntimeContext: false 不注入 runtime context,只带两个 tools 各在独立 isolate realm 里。

Host plane vs preset plane 划分标准:“A host row that injects a service cannot use this, because injection resolves before any session exists and has no agent to key by”——需 inject 的 row 在 host,不需 inject 的能力在 preset。

7. composeFrom、recompose 和 resume

7.1 composeFrom:子 agent 继承

composeFrom(agentCtx: Context, parentCtx: Context): string | undefined {
  const agentKey = scopeOf(agentCtx)
  const standing = standingMountFor(parentCtx)
  if (standing === undefined) return undefined
  this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key))
  return standing.presetId
}

同步,不读 roster 不 mount 不碰文件。直接 bind 到父的 standing mount——保证子和父看到 完全相同的 generation(不因 preset 文件编辑而不同)。rosterless deployment 返回 undefined

7.2 recompose:blank-session 切换

已有 binding → binding.rebind(newStanding.key);没有 → 首次 bind。切换是 parent re-link 不是 unmount——旧 standing mount 继续服务其他 agent,失败时 agent 状态不变。调用方负责检查 blank——有 turn 历史的 session 切换 preset 导致工具不一致。

7.3 resolveSessionPreset:resume 恢复

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
}

从 events 尾部向前 找最新 agent-preset/selected 事件,找不到 fallback 到 header.agentPreset。不能只读 header——blank-session recompose 后 header 是旧值(creation fact 不可变)。

8. Authoring:copy-only 写入模型

唯一的写操作是 整个目录复制。没有”上传组合文本”的 API——输入是已存在的 preset id + 新 id + 可选 display name,authoring 不授予 roster 之外的能力。

export async function copyComposition(
  roots: readonly PresetRoot[],
  source: AgentPreset,
  id: string,
  name?: string,
): Promise<string> {
  if (!PRESET_ID.test(id)) throw new InvalidPresetIdError(id)
  const dir = join(writableRoot(roots), id)
  if (await occupied(dir)) throw new PresetExistsError(id)
  try {
    await cp(dirname(source.path), dir, {
      recursive: true, dereference: true, force: false, errorOnExist: true,
    })
    await tightenModes(dir)
    // rewrite metadata: 保留 description, 替换 name, 去除 order
    const rendered = renderPresetMetadata({ ...name && { name }, ...source.description && { description: source.description } })
    ...
  } catch (error) {
    await rm(dir, { recursive: true, force: true })  // 失败回滚
    throw error
  }
  return dir
}

安全约束清单:

  • copy never overwriteserrorOnExist: true,occupied 检查在前
  • symlinks dereferenced:copy 自包含,不链回安装目录
  • 权限 tighten0o600(普通文件)/ 0o700(目录和可执行文件)——shipped preset 是 world-readable 的,copy 只 owner 可读
  • 失败回滚:catch 里 rm(dir, { recursive: true }) 不留半复制目录
  • 只写 user trust rootwritableRoot 找第一个 trust === 'user' 的 root,没有 throw PresetNotWritableError
  • 删除保护deleteComposition 拒绝 trust !== 'user'(shipped preset 属于 deployment)

删除后如果 settings.default 指向被删 preset:

if (this.settings?.get().default !== id) return
await this.settingsService?.mutate(
  settingsNamespace(SETTINGS_NAMESPACE),
  [{ op: 'unset', path: ['default'] }],
)

unset settings default → 暴露 config 层的 deployment default(layering:user layer 清除后 fallback 到 base layer)。

9. 完整流程图

flowchart TD
    A["session 创建"] --> B["agent factory setup(agentCtx)"]
    B --> C["agentPresets.mount(agentCtx, id)"]
    C --> D["scopeOf → agentKey"]
    D --> E["resolveMountable(id)"]
    E --> F{"broken?"}
    F -->|是| G["throw PresetMountError"]
    F -->|否| H["ensureStanding(preset)"]
    H --> I{"standing 已有?"}
    I -->|是| J["比较 CompositionStamp"]
    J --> K{"stamp 相同?"}
    K -->|是| L["复用 generation"]
    K -->|否| M["新建 generation"]
    I -->|否| M
    M --> N["createScope → PresetTree → audit"]
    L --> O["bindScopeParent(agentKey, standing.key)"]
    N --> O
    O --> P["agent 看到 preset layer"]
    style G fill:#8B0000,color:#fff
    style P fill:#006400,color:#fff

10. 容易踩的坑

坑一:service row 没配 isolate realm。 mount 时 leakedServices 审计 reject。所有 provide service 的 row 必须在 cordis:group + isolate 里。

坑二:resume 直接读 header.agentPreset 必须用 resolveSessionPreset——blank-session recompose 后 header 是旧值。

坑三:非 blank session recompose。 调用方负责 blank 检查。有 turn 历史时切换导致工具不一致。

坑四:以为编辑 preset 文件影响已有 session。 不会。旧 generation standing mount 继续存活,新 session 才用新 generation。

坑五:user preset 同名被 shipped preset shadow。 first-root-wins,system root 在前。自定义 preset 必须用不同 id。

坑六:composeFrom 父未 join preset。 rosterless deployment 返回 undefined。子 agent 也不 join,从 global layer 看工具。

坑七:bare specifier 在 user home 解析失败。 PresetTree.import() 重写只覆盖 harness 自身依赖的包。自己发布的包用 relative specifier(./my-plugin.js)。

坑八:row 等待永远不提供的 service。 inactiveRows 审计 reject mount。确保 preset 里 inject 的每个 service 在同一组合内有 provider。

11. defaultId 和 Settings 集成

defaultId 不是 config 写死就不动了——有 settings overlay:

get defaultId(): string {
  return this.settings?.get().default ?? this.config.default
}

settings 是 hot-reload 的。改 default → 下个 session 创建生效,已有 session 不受影响。settings provider detach 后自动 fallback 到 config.default(通过 ??)。

这意味着 user 可以在 settings UI 里切换默认 preset,无需重启进程、不影响正在运行的对话。

12. serviceForAgent:从外部读隔离 service

preset 用 isolate realm 隔离 service,host 代码通过正常 context 读不到。但浏览器 API proxy 需要读某个 session 的 compaction/plan-mode 状态:

export function serviceForAgent<K extends string & keyof Context>(
  ctx: Context, agent: { ctx: Context }, name: K,
): Context[K] | undefined {
  const mount = standingMountFor(agent.ctx)
  if (mount === undefined) return undefined
  for (const key of Object.getOwnPropertySymbols(store)) {
    const impl = store[key]
    if (impl.name !== name) continue
    if (withinFiber(impl.fiber, mount.fiber)) return impl.value
  }
  return undefined
}

遍历 service store 的 symbol keys,找 name 匹配 implementation fiber 在 mount subtree 内的 instance。这是 READ addressing——给持有 agent 引用的调用方用的,不是通用 host handle。

13. 实验

# 查看 shipped presets
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
ls "$repo/apps/cli/config/agent-presets/"
# → code  cordis  minimal  standard

# standard 有多少 rows
grep -c "^- id:" "$repo/apps/cli/config/agent-presets/standard/agent.cordis.yml"

# 看哪些段用了 isolate
grep -B1 "isolate:" "$repo/apps/cli/config/agent-presets/standard/agent.cordis.yml"

# minimal 的 complete:true
grep "complete" "$repo/apps/cli/config/agent-presets/minimal/agent.cordis.yml"

# discovery 的健康检查逻辑
grep -n "entryListProblem\|compositionProblem" "$repo/packages/preset/agent-presets/src/discovery.ts"

# mount 审计入口
grep -n "inactiveRows\|leakedServices" "$repo/packages/preset/agent-presets/src/mount.ts" | head -10

# session 事件追踪
grep -n "agent-preset/selected" "$repo/packages/preset/agent-presets/src/session.ts"

# ensureStanding 的 compositionStamp 检查
grep -n "compositionStamp\|sameStamp" "$repo/packages/preset/agent-presets/src/index.ts"

14. 这一部讲完了

能力接入的完整机制从底到顶:Capability Seam(inject + isolate + effect)→ cordis.yml 组合(五层 patch + !!js + group + HMR)→ Skills(Provider 分层 + lazy 加载)→ MCP 工具(syncTools + publicToolName)→ Repository Plugin(workspace .dsh/skills)→ Agent Preset(standing mount + scope parentage + generation 轮转 + composeFrom/recompose/resolveSessionPreset)。

声明式组合让一个进程同时运行多套配置的 agent session,热替换不中断已有对话,让项目携带自己的能力包——全部不需要重启。

下一部进入产品界面和可观测性层。