YAML 组合与加载

插件树不写在代码里,而是由 YAML 组合文档描述。加载器把文档解析成带稳定 id 的有序行, 再挂载成运行中的插件。对应实现:src/Cordis.Loader/。

一份组合文档长什么样

顶层只认三个键:include、plugins、patches。

include:
  - ./presets/base.yaml

plugins:
  - id: clock
    name: system-clock

  - id: llm
    name: deepseek
    config:
      apiKey: ${DEEPSEEK_API_KEY}
      model: ${DEEPSEEK_MODEL:-deepseek-flash}

  - id: legacy-tool
    name: shell-tool
    disabled: true

patches:
  - id: llm
    config:
      apiKey: ${DEEPSEEK_API_KEY}
      model: deepseek-v4-pro

行(plugins 里的条目)只允许四个键:

键必填说明
id是稳定标识符,补丁靠它定位这一行,必须非空。
name是插件在目录里注册的名字。
disabled否为真时保留配置但不挂载插件。
config否传给插件的配置子树。

出现未知键、或同一映射里键重复,都会抛 CompositionException。

插件目录:名字到工厂

文档按名字引用插件,名字到工厂的映射由宿主在 PluginCatalog 里装配。

var catalog = new PluginCatalog();

// 不带配置
catalog.Register("system-clock", () => new SystemClockPlugin());

// 带配置:YAML 的 config 子树反序列化成 TConfig,
// 并在工厂运行之前做 DataAnnotations 校验
catalog.Register<DeepSeekOptions>("deepseek", options => new DeepSeekPlugin(options));

var loader = new CompositionLoader(catalog);

TConfig 必须是有无参构造的类。给一个无配置插件写 config 段是错误。catalog.Names 与 catalog.Contains(name) 可以查询当前注册情况。

源码插件不在目录里

目录管的是宿主编译期就知道的插件。从 *.cs 源码动态加载由 Cordis.Scripting 与 Cordis.Hmr 负责。

stable id 与有序补丁

组合的可 patch 性建立在 id 上:补丁按 id 定位行,因此在别处插入、改配置或禁用其它行都不会打乱这一行。

解析顺序是确定的:include 深度优先展开,每份文档先追加自己的 plugins 行,再应用自己的 patches;调用方传入的补丁层最后跑,按实参顺序。

每份文档各自执行这两步 根文档 或内联 YAML include 深度优先展开 追加 plugins 带 stable id 的行 应用 patches 按 id 定位 调用方 补丁层 按实参顺序 RenderedComposition 不可变候选,已校验 MountedComposition 已挂载,可事务化替换 环境变量替换在每份文档与每个补丁层的行被应用之前各自展开一次;渲染与挂载是分开的两步,便于先校验再决定是否上线。
补丁按 id 定位而非按位置,所以在别处插入或禁用其它行都不会打乱目标行。

补丁条目允许五个键:id、name、disabled、config、insert。

patches:
  # 定位改配置:完整替换 config 子树,不是深合并
  - id: llm
    config:
      model: deepseek-v4-pro

  # 带上 name 作为断言:目标行的 name 不符就报错
  - id: llm
    name: deepseek
    disabled: true

  # 追加新行;insert 不能与定位键同时出现
  - insert:
      - id: extra-tool
        name: shell-tool

两条约束值得单独记住:

  • 非 insert 补丁必须有 id,否则报错;
  • insert 补丁不能同时带 id / name / disabled / config。

config 是整体替换

定位补丁替换的是完整的 config 子树,不做逐键深合并。 想只改一个字段,补丁里仍要给出该行需要的完整配置。

环境变量替换

标量值里的 ${...} 会在行被应用之前展开,每份文档和每个调用方补丁层各自展开一次。

写法行为
${VAR}取变量值;变量未设置是错误。
${VAR:-default}变量未设置时回退到 default。

变量名遵循 [A-Za-z_][A-Za-z0-9_]*。映射的键不参与替换,只有值会。另外,若某个值别名会改动被用作映射键的节点,该文档会被拒绝。

渲染与挂载

渲染与挂载是分开的两步,便于先校验再决定是否上线。

方法作用
RenderFileAsync(path, ...patchLayers)解析文件并校验,产出 RenderedComposition,不挂载。include 路径相对各自的引用文件解析。
Render(yaml, ...patchLayers)解析内联 YAML。内联文档没有基准目录,因此不允许 include。
LoadFileAsync(context, path, ...patchLayers)渲染文件并挂载,返回 MountedComposition。
LoadAsync(context, yaml, ...patchLayers)渲染内联 YAML 并挂载。

调用方补丁层每层要么是一个 patches 映射,要么是一个补丁序列,且不能贡献基准行或 include。

RenderedComposition 是不可变候选,本身就是 IReadOnlyList<PluginSpec>:

var rendered = await loader.RenderFileAsync("composition.yaml", overrideLayer);

Console.WriteLine(rendered.Yaml);   // 确定性 YAML 渲染,ToString() 同此
foreach (var spec in rendered)
{
    // Id / Name / Disabled / Enabled / ConfigYaml / Source
    Console.WriteLine($"{spec.Id} <- {spec.Source}");
}

PluginSpec.Source 记录最后改动这一行的文档或调用方层,排查”这个配置到底哪来的”很有用。

AppBoot 的用户补丁层

CompositionLoader 只负责按顺序应用调用方交给它的层;Tether 进程的自动发现、路径解析和优先级由 AppBoot 统一决定。CLI 与 Headless 都走同一条启动边界,优先级从低到高固定为:

  1. Composition 自身的 include、plugins 与 patches;
  2. <home>/profiles/<profile>/cordis.patch.yml;
  3. <home>/cordis.patch.yml;
  4. 每个 --patch <path>,按命令行出现顺序。

profile 层与 home 层是可选的:文件不存在就当作空层,不创建目录或空文件。--patch 指向的文件必须 存在;相对路径以进程当前工作目录为基准,而不是相对 composition 文件。根 composition、include 和 每个实际读取的 patch 文件都必须是严格 UTF-8;UTF-16 BOM 也不会触发自动转码。

每个 patch layer 都携带绝对 source path,因此最终 ICompositionInfo.Rows[].Source 指向最后赢得该行的 真实文件。删除高优先级层后,同一个 stable row 会原位回退到下一层,不会因为覆盖而改变 roster 顺序。

home 只解析一次并转成绝对路径,来源依次为显式 --home / BootOptions.Home、非空白 TETHER_HOME、当前用户目录下的 .tether。空白的 TETHER_HOME 不会被当成当前工作目录。 profile 在 AppBoot 与 tether(Web 宿主 / 桌面壳)中默认为 local(发行模板为 web 栈 bundles: ["web"]),开发 CLI(Tether.Cli)在未显式指定 --profile 或 --composition 时默认 cli(随附 bundles: ["cli"]);并且必须是普通的单段名称;空白、.、..、绝对路径,以及包含 / 或 \\ 的值都会在读取任何文件或挂载任何插件前被拒绝。

发行包在安装根随附 profile 模板:<install>/profiles/<name>/profile.yaml(cli、headless、 local、web)。profile 目录缺 profile.yaml 而名字命中随附模板时,boot 会把模板里缺席的文件 物化进 <home>/profiles/<name>/——幂等、并发安全、绝不改写既有文件;local 的首次 tether 启动走的就是这条路径(物化为 web 栈)。要从模板另建一个 custom profile,用 --from-default-profile <模板名>: shipped 名被保留不能作为目标,目标目录已存在(哪怕没有 profile.yaml 的残留目录)也会被拒绝, 并发创建只有一个进程成功。

可选只表示“允许不存在”

自动发现的文件一旦存在,无法读取、UTF-8 非法、YAML 或 patch schema 非法、引用未知 target,都会让 启动明确失败。Boot 会先读取、解析并校验完整候选,再挂载第一个插件;坏的后层不会让前面的 provider 短暂上线,失败仍遵循同一套 rollback 与 cleanup 契约。

发行组合把 ralph-workflow 行以 disabled: true 保留在 base.yaml(上游 dsh 2026-09-12 决策: 默认目录不宣传一个完成只能由 worker 自我声明、没有独立评估器与后台收集/调度器的能力)。需要恢复 的部署写一层用户补丁即可,例如 <home>/cordis.patch.yml 或 --patch <path>:

patches:
  - { id: ralph-workflow, disabled: false }

disabled 状态参与未变行判定,翻转后该行以新句柄正常挂载;直接删除行而不是禁用它会让补丁失去 目标。工具面暴露为 ralph_workflow(上游工具名为 ralph),这是既有命名偏离,按 deviation 登记。

tether --dump-config-schema:把组合投影成 JSON Schema

在 --dump-config 的行表之外,CLI 还提供把整份组合(含 disabled 行)投影成一份 JSON Schema 2020-12 文档的开关:

tether --profile local --dump-config-schema > schema.json

stdout 只有 JSON 文档本身;收集期诊断逐行打到 stderr(tether: <level>:[/N] <message>), 文档不完整时进程以 1 退出(完整时 0)。两个 dump 开关互斥,且都不接收 app 参数 (同给或带位置参数都会以参数错误 2 退出)。

收集走的就是 AppBoot 那条层序:bundles → profile cordis.patch.yml → home → 每个 --patch,--from-default-profile 的初始化语义也一样。与真实启动不同的是它只做收集, 不做求值:${ENV} 标量保持原样不替换(未设置的变量也不会让收集失败),不构造 config 对象、不调用任何插件工厂或 ApplyAsync。

每行的 config 类型(Register<TConfig>)经反射投影成 $defs 里的 object schema: 可写属性映射字段,[Required] 进 required,DataAnnotations 约束([Range]、 [StringLength]、[RegularExpression]、[AllowedValues] 等)落到对应关键字, 缺省值经无参构造探测后写成 default 注解,共享/递归类型落成具名 $ref。 Volatile<T> 成员展开为快照类型并打 x-cordis.volatile 注解(与 D1-1 的装载期规则同一份 放置约束,坏位置在收集期就判 error);[ConfigSecret] 成员打 x-cordis.secret。 所有标量位置的 anyOf 多收一个 #/$defs/envExpression 分支——惰性 ${VAR} 标记在 schema 层面就是被允许的字符串形态,而不是一个待求值表达式。

文档尾部挂 x-cordis 块:profile、complete、entries(每行 path/id/name/status/configRef,status ∈ schema|absent|error)、 diagnostics、patchSchema(指向 $defs/patchList,供校验用户补丁层)。 items 指向 $defs/entry:entry 本体是 entryMetadata + 逐名 if name==X then config∈<该名的 configRef 集合> 规则——某些 config 必填的名字还会被 dormant 规则包裹(disabled 为 true 或 ${ENV} 标记的行 config 可缺席)。 $defs/patch 复用同一套规则,并对每个可寻址 stable id 追加 config 的目标 $ref (重声明或解析失败的 id 不再可寻址,补丁对它们 warn 并跳过)。

catalog 错配也会如实报告:某行的 name 在当前 catalog 里没有注册(比如用 CLI 的目录去 dump web profile——web 侧插件名只有 WebHost 注册)时,该行记 status: "error" 并产生 error 诊断,complete=false、进程退出 1,但 JSON 文档照常完整输出。

事务化替换

MountedComposition 拥有它暴露的那批句柄,并且串行地替换它们。

await using var mounted = await loader.LoadFileAsync(ctx, "composition.yaml");

// 改完 YAML 后重新渲染,再整体替换
var candidate = await loader.RenderFileAsync("composition.yaml");
await mounted.ReplaceAsync(candidate);

替换的关键性质是未变的行保留句柄身份,不会被无谓地重启。判定一行是否”未变”看四项:id、插件 name、disabled 状态,以及完整配置。配置的比较规则分两级:

  • 规范化后的 config YAML 相同 → 相等;
  • YAML 不同时,对绑定后的值做结构比较(上游 deepEqual):volatile 子树永远按引用相等,普通字段逐项相等才算相等;
  • 其余情况该行被替换。

volatile 配置引用

配置属性可以声明为 Volatile<T>——声明即 volatile 字段(上游 schemastery .volatile() 的属性载体)。Volatile<T>.Get() 返回当前不可变快照,引用本身可长期持有;通用相等比较视两个 volatile 引用相等,载荷不参与。

volatile 字段要求固定对象路径:声明在集合元素之下或另一个 volatile 之下的字段会在渲染时被拒绝。绑定后缺省的 volatile 字段也会得到引用(上游 .volatile() 输出总是引用)。

volatile-only 更新不 remount:raw YAML 不同而普通字段有效值一致时,替换路径把新快照逐路径提交进运行中句柄的活引用,并以实例本地 emit 派发 loader/volatile-update(只有该 fiber 激活作用域下的监听器可见,监听器失败只记日志)。提交前候选先经 PluginHandle.ResolveConfig 钩子(internal/config 等价)与配置校验;钩子拒绝或校验失败 → raw 保留、运行引用不变、结果携带诊断。volatile-only 更新不发出 fiber 生命周期事件(不触发 Tether 的 fiber restart 通知)。

成员语义
Composition最后一次成功挂载或成功回滚的组合。
Rows有序的生效行,包含被禁用的行。
Handles有序的已挂载句柄,不含被禁用的行。

两条硬约束

句柄的生命周期归组合所有:调用方可以检视它们,但应当 dispose 整个 MountedComposition,而不是单独 dispose 某个句柄。
另外,不能从组合内某个插件的生命周期回调里调用 ReplaceAsync (会抛 InvalidOperationException),候选也必须来自同一个目录实例。

失败与回滚

替换失败不会把系统留在半吊子状态:候选行被清理,随后依据渲染快照重建上一次成功挂载的那批行。初次挂载失败则清理已挂载的行并回收组合作用域。

所有组合层面的失败都通过 CompositionException 报告,覆盖的范围包括:

  • YAML 非法、补丁顺序错误、未知键;
  • 引用了目录里不存在的插件名;
  • include 成环;
  • 环境变量无法解析;
  • 插件配置校验不通过;
  • 挂载、替换、回滚过程中的运行期生命周期失败。

清理阶段自身若再出错,异常会被聚合进同一个 CompositionException 的内层,不会丢失原始失败原因。

Profile bundles 栈装配模型(#308)

在产品层宿主(Tether.Boot)中,profile.yaml 已正式退役旧的顶层 composition 单文件键,转向 bundles: 有序栈 驱动装配:

# ~/.tether/profiles/local/profile.yaml
bundles:
  - base
  - web
  - my-extension-bundle
  • 空根 + 有序 Patch 层:当 profile 声明 bundles: 时,组合装配以一个空根(plugins: [])起步,随后由 ProfileBundleStack 按列表声明顺序逐层叠加入补丁;
  • 安装优先(Installation-First)解析:每个 bundle 名称首先查找用户已安装的扩展包(installed kind:bundle 的 bundle.name),若无则回退至官方发布的预置组合(shipped bundles/<name>.yaml)。若声明不存在或文件损坏,严格 Fail-Closed,绝不静默回退;
  • 身份绑定:组合源身份(compositionSource)直接绑定 profile manifest,启停或调整 bundle 顺序不会导致组合身份哈希异常漂移。

下一步

在 GitHub 上编辑此页