第 7 周 · Tether 源码课
YAML Composition:把插件树写成可替换的配置
学完你能做到
- 从一个 YAML row 追到 catalog factory 与 mounted PluginHandle
- 解释 stable id、name、config 与顺序各自承担什么
- 预测 include、target patch 和 insert 的最终 row 顺序
- 在临时目录渲染 patch,不修改正式 composition
课程进度
- 第 1 周
- 第 2 周
- 第 3 周
- 第 4 周
- 第 5 周
- 第 6 周
- 第 7 周
- 第 8 周
- 第 9 周
- 第 10 周
- 第 11 周
- 第 12 周
- 第 13 周
- 第 14 周
- 第 15 周
- 第 16 周
一句话先懂
Composition 是“这一进程选择哪些 plugin、按什么配置挂载”的数据;Boot 只负责把它交给 Loader。
YAML 用 stable id 指定某一行,PluginCatalog 用 name 找 factory,CompositionLoader 先渲染再挂载,MountedComposition 持有整组 handle。
前六周回答了一个 plugin 如何工作。本周回答另一个软件工程问题:几十个 plugin 由谁选择?如果 CLI 与 Headless 需要不同 Provider,是否要复制一整段 C# 启动代码?
先看大图
一个类比:演出节目单与演员名册
把 YAML 看成节目单:每一行有不会随演员变化的节目编号 id,也有本场由谁出演的 name。PluginCatalog 是演员名册,知道一个名字对应哪家 factory。Loader 先把基础节目单、分场安排和修改单合并,再让 MountedComposition 管理整场演出。
id 让 patch 能说“改 llm-pi-ai 这一席”,不必依赖它恰好排第 17 行。replaceName 可以把同一席从真实 LLM Provider 换成 ReplayLlm,而 Consumer 仍依赖相同 seam。
类比边界:YAML row 的文档顺序是 mount 顺序,不等于 dependency order;Inject 仍由 Registry 动态判断。Loader 还包含配置反序列化、DataAnnotations 校验、失败回滚和并发替换,这些不是节目单类比能表达的。
从 CLI 的三层 YAML 开始
apps/Tether.Cli/compositions/v0.3-cli.yaml 只有一个 include:
include:
- ./bundles/cli.yaml
发布时,bundle 被复制到 app 输出目录。公共的 session store 与 Coordinator 先由 src/Tether.Bundles/bundles/base.yaml 声明:
plugins:
- { id: session-store, name: session-jsonl,
config: { root: "${TETHER_SESSION_ROOT:-.tether/sessions}" } }
- { id: session-persistence, name: session-persistence }
src/Tether.Bundles/bundles/cli.yaml include base,只替换 replay model 并插入 CLI consumers:
include:
- ./base.yaml
patches:
- { id: llm-pi-ai, name: llm-multi, replaceName: replay-llm, config: null }
- { id: default-model, name: agent-default-model,
config: { provider: replay, model: replay-model } }
- insert:
- { id: session-stats, name: session-stats }
- { id: cli, name: cli }
Loader 深度优先展开 include,所以 base rows 先进入 builder;随后 CLI patches 定位 llm-pi-ai 和 default-model,最后 append 一组 CLI 专用 rows。默认 composition 因而只有一个 session-store,实际 factory 是 session-jsonl。
store row 本身就是 provider seam:要接入 out-of-tree provider,不在 CLI 里插入第二个 store,而是应用 canonical replacement patch(stable id 仍是 session-store,replaceName 指向目标 factory)。first-party SQLite Session provider 已按 #150 下线,JSONL 是唯一 first-party 实现:
patches:
- { id: session-store, name: session-jsonl,
replaceName: <out-of-tree-provider>,
config: { ... } }
所以 session-persistence Coordinator 和 projection/resume/title 等 consumers 都不变;composition 中也不会同时出现两个 store authority。
id、name、config 不可互换
| 字段 | 身份 | patch 行为 |
|---|---|---|
id | 这一 composition slot 的 stable identity | target patch 必须用它定位;不能重复 |
name | PluginCatalog 中的 factory 名 | 可作为预期值防止改错,也可通过 replaceName 替换 |
config | 传给该 factory 声明的 config type | target patch 整体替换 config subtree,不做深合并 |
disabled | row 保留但不 mount | 可由 patch 开关;disabled row 延迟 catalog resolution |
例如 base config 是 { greeting: base, timeout: 10 },patch 写 { greeting: new } 后,timeout 不是 10,而是 config type 的默认值。把它误当深合并会制造非常隐蔽的配置错误。
源码放大镜:每层只做一件事
按下面顺序阅读:
src/Cordis.Loader/PluginCatalog.cs:注册name → config type + factory;未知 name 或多余 config 都失败。src/Cordis.Loader/CompositionLoader.cs:include、环境变量、ordered patch、config validation,产出 immutable candidate。src/Cordis.Loader/MountedComposition.cs:拥有 handles;替换时保留等价 row,按旧顺序的反向卸载 changed/removed rows,失败则尝试恢复 last good composition。src/Tether.Bundles/BundleCatalogs.cs:把发行 profile 使用的名字登记到 catalog。src/Tether.Boot/AppBoot.cs:创建 root、load、提供只读 composition info、Start;启动失败时回收 mounted composition 与 context。
动手实验:在临时目录渲染 patch
实验目标:亲手证明 target patch 不移动 row、insert 只追加、disabled row 保留但不 mount;不修改仓库内任何正式 YAML。
1. 预测
基础 rows 是 first(alpha)、second(beta)。patch 把 stable id first 的 name 换成 beta,禁用 second,再插入 third(gamma)。
先写出你预测的最终 id / name / enabled 三行。first 会不会跑到末尾?
2. 准备临时项目
在仓库根目录执行以下 macOS / Linux 命令。所有新文件都位于系统临时目录:
repo_root=$PWD
lab_dir=$(mktemp -d)
dotnet new console --name CompositionLab --output "$lab_dir" \
--framework net10.0 --no-restore
dotnet add "$lab_dir/CompositionLab.csproj" reference \
"$repo_root/src/Cordis.Loader/Cordis.Loader.csproj"
把下面两段分别保存为 $lab_dir/base.yaml 与 $lab_dir/patch.yaml:
# base.yaml
plugins:
- { id: first, name: alpha }
- { id: second, name: beta }
# patch.yaml
patches:
- { id: first, name: alpha, replaceName: beta }
- { id: second, disabled: true }
- insert:
- { id: third, name: gamma }
将临时项目的 Program.cs 替换为:
using Cordis;
using Cordis.Loader;
var catalog = new PluginCatalog();
foreach (var name in new[] { "alpha", "beta", "gamma" })
{
catalog.Register(name, () => Plugin.From(_ => { }));
}
var loader = new CompositionLoader(catalog);
var rendered = loader.Render(
await File.ReadAllTextAsync(args[0]),
await File.ReadAllTextAsync(args[1]));
foreach (var row in rendered)
{
Console.WriteLine($"{row.Id,-8} {row.Name,-6} enabled={row.Enabled}");
}
3. 运行并观察
dotnet run --project "$lab_dir/CompositionLab.csproj" -- \
"$lab_dir/base.yaml" "$lab_dir/patch.yaml"
预期顺序是:
first beta enabled=True
second beta enabled=False
third gamma enabled=True
如果你使用 PowerShell,也可以在系统临时目录创建同样三个文件并运行最后一条 dotnet run;关键是 ProjectReference 指向当前仓库的 src/Cordis.Loader/Cordis.Loader.csproj。
4. 用仓库测试解释
dotnet test tests/Cordis.Loader.Tests/Cordis.Loader.Tests.csproj \
--filter FullyQualifiedName~CompositionLoaderTests
重点看 Target_patch_replaces_complete_config_without_moving_the_row、Insert_appends_rows_and_later_patch_can_target_the_inserted_id 与 Includes_expand_depth_first_before_local_and_caller_layers。
检查理解
1. 为什么 patch 用 stable id 而不是数组下标?
查看答案
include 与 insert 会改变总行数和下标;stable id 表达 slot 身份,不随无关行插入而漂移,也允许 Loader 在替换时判断哪一个 handle 可保留。
2. YAML 中 row 排在 Provider 前面,依赖它的 Consumer 会 apply 失败吗?
查看答案
不一定。row 会按文档顺序 mount,但带 Inject 的 Consumer 可先进入 pending;后续 Provider publication 会唤醒它。mount 顺序与 activation dependency graph 是两个维度。
3. YAML 能否写一个仓库从未登记的 name,从而执行任意 C# 类型?
查看答案
不能。enabled row 必须由 PluginCatalog 解析;普通 composition 只能选择 host 登记的 factory。第 15 周的 source plugin 也需要明确的 Scripting/HMR 加载路径,不是 YAML 任意反射。
本周带走
- AppBoot 只创建 root 并交给 Loader;插件树的选择和配置留在 composition。
- include 深度优先,patch 按 stable id 有序应用,target patch 保持位置,insert 追加,config 整体替换。
- RenderedComposition 是已验证 candidate;MountedComposition 才拥有运行中的 handles 与 replace/dispose 生命周期。
下一周不再增加新概念,而是选一行 composition,从 YAML 一直追到逆序回收。详细格式可查YAML 组合与加载。