第 7 周 · Tether 源码课

YAML Composition:把插件树写成可替换的配置

预计 90–110 分钟 先修:第 3–6 周:Plugin、Scope、Service 与 Events、能阅读简单 YAML

学完你能做到

  • 从一个 YAML row 追到 catalog factory 与 mounted PluginHandle
  • 解释 stable id、name、config 与顺序各自承担什么
  • 预测 include、target patch 和 insert 的最终 row 顺序
  • 在临时目录渲染 patch,不修改正式 composition

课程进度

  1. 第 1 周
  2. 第 2 周
  3. 第 3 周
  4. 第 4 周
  5. 第 5 周
  6. 第 6 周
  7. 第 7 周
  8. 第 8 周
  9. 第 9 周
  10. 第 10 周
  11. 第 11 周
  12. 第 12 周
  13. 第 13 周
  14. 第 14 周
  15. 第 15 周
  16. 第 16 周

一句话先懂

Composition 是“这一进程选择哪些 plugin、按什么配置挂载”的数据;Boot 只负责把它交给 Loader。

YAML 用 stable id 指定某一行,PluginCatalog 用 name 找 factory,CompositionLoader 先渲染再挂载,MountedComposition 持有整组 handle。

前六周回答了一个 plugin 如何工作。本周回答另一个软件工程问题:几十个 plugin 由谁选择?如果 CLI 与 Headless 需要不同 Provider,是否要复制一整段 C# 启动代码?

先看大图

profile YAML include + patches Render stable id rows 校验 + 正规化 PluginCatalog name → factory RenderedComposition 有序、已解析、尚未运行 id · name · config · enabled MountedComposition 按 row 创建并拥有 PluginHandle ReplaceAsync / DisposeAsync Context.Plugin Inject 决定 active / pending Root Context Registry + Events + Scope AppBoot:创建 root → LoadFileAsync → Provide composition info → StartAsync 入口不手写整棵对象图;选择留在 YAML 与 catalog
“一切皆插件”并不表示 YAML 会执行任意代码;它只能选择 host 已登记到 catalog 的 factory。

一个类比:演出节目单与演员名册

把 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 identitytarget patch 必须用它定位;不能重复
namePluginCatalog 中的 factory 名可作为预期值防止改错,也可通过 replaceName 替换
config传给该 factory 声明的 config typetarget patch 整体替换 config subtree,不做深合并
disabledrow 保留但不 mount可由 patch 开关;disabled row 延迟 catalog resolution

例如 base config 是 { greeting: base, timeout: 10 },patch 写 { greeting: new } 后,timeout 不是 10,而是 config type 的默认值。把它误当深合并会制造非常隐蔽的配置错误。

源码放大镜:每层只做一件事

按下面顺序阅读:

  1. src/Cordis.Loader/PluginCatalog.cs:注册 name → config type + factory;未知 name 或多余 config 都失败。
  2. src/Cordis.Loader/CompositionLoader.cs:include、环境变量、ordered patch、config validation,产出 immutable candidate。
  3. src/Cordis.Loader/MountedComposition.cs:拥有 handles;替换时保留等价 row,按旧顺序的反向卸载 changed/removed rows,失败则尝试恢复 last good composition。
  4. src/Tether.Bundles/BundleCatalogs.cs:把发行 profile 使用的名字登记到 catalog。
  5. 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 组合与加载。

在 GitHub 上编辑此页