08 组合层深读:配置树、loader 与 preset¶
本章回答一个问题:
agent.cordis.yml里那棵树是怎么长出来的,一个进程为什么能同时跑多个不同组成的 agent?这是前几章"内核"与第 07 章"入口"之间被跳过的中间层——组合层。我们会先理解上游的配置树三层归属与 loader 结算,然后在 mini 里实现一个最小的 preset roster(会话级 agent 组合的选择与挂载)。对应 dsh 真实源码:
vendor/cordis(loader/组合结算)+packages/preset(per-session agent composition)+apps/cli/config/agent-presets/(四种出厂 preset)。mini 复现了 roster + 挂载一条(miniharness/preset/presets.py)。
8.1 直觉:为什么要有组合层¶
前几章的内核只有"一个 agent 怎么跑"。但 dsh 的现实是:
- 一个进程要同时服务标准模式、极简模式等多个 agent,它们工具目录不同、prompt 不同、还能各跑各的;
- 工具的实现(bash、文件系统、web 搜索)是进程级单例,不应该每个会话复制一份;
- 所以必须把"有什么实现"(host plane)和"这个会话用哪些"(preset)分层——这正是
packages/preset/README.md:5的一句话:一个 preset 就是"挂在一个 agent 作用域下的一棵小组合树",让一个进程跑几个不同组成的 agent 而不打架。
8.2 上游设计:三层归属与 roster¶
8.2.1 组合树的三层归属¶
agent.cordis.yml 是 YAML 形式的 Cordis 组合描述,最终由 loader 结算成内存里的插件树。归属规则只有一条主线(packages/preset/README.md:14):
跨会话设施是进程单例,留在 host 组合;preset 只携带"这一个 agent 贡献给它们的东西"。
落到结构上:
| 层 | 是什么 | 谁能用 |
|---|---|---|
| host plane | 进程级组合(bundle 底座、注册表、tokenMeter、jobs 注册表等) | 所有 agent |
| agent plane | 每个 agent 会话挂载的组合(preset 贡献的工具选择、prompt) | 该 agent 自己 |
isolate realm |
带 isolate 的 group,服务行必须挂进去才能隔离 |
该 realm 内 |
realm 规则的直接后果(apps/cli/config/agent-presets/standard/agent.cordis.yml:11-18 注释):service 行不挂进 isolate realm 就会落到根 realm,第二个会话想再挂就冲突——所以命名了进程级全局服务的行在挂载时被拒绝,而不是让下个会话撞车。
8.2.2 loader:从 YAML 到插件树¶
loader 的 include 展开(上游由 @deepseek-ai/cordis-plugin-include 实现,packages/boot/app-boot/src/index.ts:16 安装 mountRootInclude;vendor/cordis 本体在 src/ 下只有 context/events/fiber/index/loader 相关源码,无 loader 独立目录)做的核心事可压缩成三条:
- include 展开:组合里
include: 'file.yml'的行先被替换成目标文件的内容(递归),这是"一个 preset 引用共享片段"的做法; - 插件加载:每条 entry 的
plugin字段导入真实模块,取回inject/apply元数据(provides已废除——服务在 apply 期动态登记); - 结算:把整棵树交给插件管理器,按依赖激活——这正是 mini 第 2 章
RegistryService做的事(miniharness/core/scope.py),只是上游还有 scope/fiber/carrier 的完整实现。
mini 对照:这套 loader 已整件移植为
miniharness/loader/(对齐vendor/loader+vendor/include),见 §8.3.1;mount_root_include装cordis:include/cordis:group内建、补丁展开用loader.patch.apply_entry_patches。
8.2.3 preset roster:目录列表即名单¶
四种出厂 preset(apps/cli/config/agent-presets/{standard,code,minimal,cordis}/)的名单不维护在代码里——packages/preset/README.md:12 明说:预置名单就是那个目录的列举,目录里有什么就是什么。这避免了"代码里的名单"与"磁盘上的目录"两处漂移。
模式差异(前面报告 04 页议题 1 有完整版,这里只提组合视角):
- 标准模式:agent-plane 组合,每进程挂一次,会话经 scope parentage 加入;工具目录跨模式不变(为了请求缓存稳定);
- 极简模式:persona
complete: true(系统提示即全部上下文)+ 双工具(bash + str_replace_editor),无运行时上下文、无压缩——"只换工具集就得到全新产品形态"的示范。
8.3 mini 复现:preset roster 与挂载¶
mini 组合层有两块:装载树(miniharness/loader/,§8.3.1)把 YAML 变成活的插件 fiber;preset roster(miniharness/preset/presets.py,§8.3.2 起)决定一个进程里各 agent 用哪些工具与 prompt。组合层支持 YAML(boot/composition.py,pyyaml 硬依赖承载);preset 清单用 JSON 承载(载体简化,约定与上游一致)。roster 与上游 alpha.1 的多根分层一致:shipped root(随包分发的内置 preset,system trust、authoring 只读)→ 配置 roots → harness-home(user),同 id first-root-wins(同上游 discoverPresets 的 resolvedRoots 顺序);清单文件双读(preset.json 或上游形态 agent.cordis.yml 经 translate_cordis_composition 翻译)。本章演示以仓库自带目录为例:
miniharness/preset/ # shipped root(system trust)
├── standard/preset.json # 标准模式:8 工具 + 运行时上下文
└── minimal/preset.json # 极简模式:2 工具 + fixed-prompt(complete: true)
8.3.1 装载:Loader 活树与根 Include(miniharness/loader/)¶
boot() 把 YAML 变成活的插件树,靠的是一套 cordis loader 的整件移植(对齐上游 vendor/loader + vendor/include):
EntryTree——条目树的载体:扁平store(id → Entry)、entries()递归子树、resolve('include:candies')这类SEP下钻 id、import_('cordis:include')取内建插件;Entry——一个可配置插件节点:update()三态(已激活则 diff 后经internal/update重载、未激活则init()、禁用则 dispose),disabled沿 parent 链上溯并对!!js表达式求值;EntryGroup/Group——group: true条目的子列表宿主(组载体);Loader服务——挂三个钩子:internal/config(树载体配置保持字面、普通条目激活期 interpolate)、internal/update(写回entry.options.config并持久化)、internal/plugin(插件自销毁时回写disabled: true);Include——文件背书子树:读/写目标文件、initial首写、apply_entry_patches展开补丁、YAML 回写时!!js原样保留。
boot() 的形态因此是:
root = Context(name="root")
root.baseUrl = os.path.dirname(os.path.abspath(config_path))
root.plugin(Loader, {"baseUrl": root.baseUrl}) # 装载服务
mount_root_include(root, abs_config, patches) # 挂根 cordis:include 条目
loader = root.get("loader")
_settle_loader(loader) # 排空在途转换
_assert_loader_activated(loader, bin_name) # 三态审计,未激活即 fail loud
组合的依赖驱动激活仍是第 2 章 RegistryService 的机制:每条 entry 的 inject 缺失即保持 PENDING,提供方在 apply 期 provide 后经依赖追踪唤醒到 ACTIVE;_assert_loader_activated 在启动末尾逐条检查——ACTIVE 通过、FAILED 重抛原错误、PENDING 点名缺失的注入服务。条目 YAML 用上游同款方言 entryListSchema(JSON_SCHEMA + !!js)解析:只有 JSON 标量与 !!js 节点,yes/日期等保持字符串。条目级 isolate/intercept 选项也已对齐(loader/isolate.py:Local/Global realm + loader/patch-context 迁实现 + realm GC;loader.await 门控由 loader 服务的检查谓词承担),仅 Node ESM internal.ts 不适用,登记在 verified-diffs.md §3.26。
验证:python -m unittest tests.test_loader_tree tests.test_loader_include -v。
8.3.2 领域对象(miniharness/preset/presets.py)¶
@dataclass(frozen=True)
class PersonaConfig:
complete: bool = False # True = 系统提示即全部上下文
include_runtime_context: bool = True
system_prompt: str | None = None
@dataclass(frozen=True)
class Preset:
id: str
name: str
description: str
order: int
tools: list[str] = field(default_factory=list)
persona: PersonaConfig = field(default_factory=PersonaConfig)
provides: list[str] = field(default_factory=list) # 进程级服务声明(默认空)
broken: str | None = None # 发现期健康标记:缺 preset.json / 清单损坏 → 挂载期拒绝
8.3.3 roster:目录列表即名单¶
class PresetRoster:
def _scan(self) -> dict[str, Preset]:
presets: dict[str, Preset] = {}
try:
children = sorted(self.root.iterdir())
except FileNotFoundError:
return presets # 根缺失 → 空册(上游 ENOENT → [])
for child in children:
if not child.is_dir() or not PRESET_ID.match(child.name):
continue # 只认合法 preset id 的目录
manifest = child / "preset.json"
if not manifest.is_file():
# 缺清单 → 占位 broken 行:目录仍占用 id,须删除目录或补齐文件
presets[child.name] = Preset(
id=child.name, name=child.name, description="", order=0,
broken="the composition file preset.json is missing — "
"the directory still occupies the id; "
"delete it or restore the file")
continue
try:
p = load_preset(child)
except (OSError, ValueError, KeyError, TypeError) as error:
# 清单损坏 → 同样是 broken 占位行(上游 compositionProblem → broken)
presets[child.name] = Preset(
id=child.name, name=child.name, description="", order=0,
broken=f"the composition is unloadable: {error}")
continue
presets[p.id] = p # 跨 root 同名 id first-root-wins(同上游 discoverPresets resolvedRoots 顺序)
return presets
ids() 按 order 排序返回名单;resolve(preset_id) 未知 id → KeyError(fail loud)。已开 sessions 的 preset 选择在会话开始(首 turn/start)后锁定(PresetLockedError)。新增一个 preset = 新建一个目录,roster 代码一行不改。
8.3.4 挂载:只在 agent 作用域开视图¶
Preset.mount 是把"host 实现"与"会话选择"粘起来的关键,三条不变量逐条执行:
def mount(self, ctx, agent_scope, host_tools) -> ToolRegistry:
missing = [t for t in self.tools if host_tools.resolve(t) is None]
if missing:
raise RuntimeError(f"preset {self.id} 声明了 host 未提供的工具: {', '.join(missing)}")
for key in self.provides:
if ctx.get(key) is not None:
raise RuntimeError(
f"preset {self.id} 声明进程级服务 {key},但 host 已提供(拒绝挂载)")
view = ToolRegistry(agent_scope)
for name in self.tools:
view.register(host_tools.resolve(name), scope=agent_scope)
return view
- 不造实现:工具本体留在 host 的
ToolRegistry,preset 只把引用注册到 agent 作用域(register(tool, scope=agent_scope),复用第 3 章的作用域化注册表); - host 缺工具 → fail loud:preset 声明了 host 没有的东西,就是组合坏了,立刻报错;
- 进程级冲突 → 拒绝挂载:preset 声明
provides命中 host 已有服务时拒绝——这就是上游"命名进程级全局服务的行在挂载时被拒绝"的 mini 版。
8.3.5 与 loop 对接¶
挂载返回的 ToolRegistry 视图可直接喂给 AgentLoop:
roster = builtin_roster()
agent_scope = host.create_scope("agent:1")
view = roster.resolve("minimal").mount(host, agent_scope, registry)
loop = AgentLoop(session, adapter, view, ctx,
system_prompt=roster.resolve("minimal").persona.system_prompt)
两个 preset 用同一个 host 注册表、两个 agent 作用域——一个进程同时跑标准与极简 agent 的形态就成立了。
8.4 硬性规定(被测试固定)¶
- roster = 多根目录列表:发现 = 依序扫描 shipped → 配置 roots → harness-home,跨 root 同名 id first-root-wins(同上游 discoverPresets 的 resolvedRoots;上游 alpha.1 起为分层制);新增 preset 不碰代码。占位 broken 行的
broken字段使该 preset 在挂载期被拒(Preset.mount先检查self.broken再挂载)。 - 挂载只开视图:host 注册表不变,agent 作用域只看到 preset 声明的工具。
- host 缺工具 fail loud:preset 声明了 host 没有的工具 →
RuntimeError。 - 进程级冲突拒绝挂载:
provides命中 host 已有服务 →RuntimeError,而不是覆盖。 - 未知 preset fail loud:
resolve未知名 →KeyError。 - 装载树按依赖激活:
group: true条目成嵌套子树,!!js在各自条目激活期求值;启动末尾未激活条目即 fail loud(PENDING点名缺失的注入服务、FAILED重抛原错误)。 - Include 回写保真:
include目标文件里的!!js在回写时(dump_js_expr_yaml)原样保留、不被求值。
验证:python -m unittest tests.test_presets tests.test_loader_tree tests.test_loader_include -v。
8.5 检查点¶
- 说出组合树三层归属,并解释"进程级服务挂载时被拒绝"为什么比"下个会话撞车"好;
- 给
PresetRoster新增一个 preset 目录,ids()自动包含它(一行代码不改); - 手动构造一个
provides与 host 冲突的 preset,观察挂载被拒绝且 host 服务未被覆盖; - 说出 mini 相对上游的载体简化(YAML→JSON)与语义一致(组合选择语义不变);
- 画一棵含
include与group的条目树,说出EntryTree.resolve('include:candies')如何下钻、PENDING条目在启动审计时报什么。
下一章:Agent 干预面——宿主怎么唤醒、转向、取消一个运行中的 agent(steer/inject/cancel/whenIdle)。