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;调用方传入的补丁层最后跑,按实参顺序。
补丁条目允许五个键: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 都走同一条启动边界,优先级从低到高固定为:
Composition自身的 include、plugins 与 patches;<home>/profiles/<profile>/cordis.patch.yml;<home>/cordis.patch.yml;- 每个
--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 顺序不会导致组合身份哈希异常漂移。