Composition Root 与运行
apps/Tether.Headless 是无界面宿主:从 canonical Bundles YAML 挂载插件树、
跑一次 Agent 交互,并把结果以 JSON 快照打到标准输出。Tether.Cli 使用同一套
Boot → Loader → Bundles 组合,只额外提供交互 surface。对应实现:apps/Tether.Headless/。
它证明了什么
架构规则里的第三条是”Composition Root 收敛到 Boot”。这个宿主是那条规则的具体形状:
- Headless 与 CLI 都只负责参数、日志、console/host 生命周期和退出码;
Tether.Bundles是 Host 侧唯一的 catalog authority,插件树由 Loader 从 YAML 挂载;- 插件树的形状写在 Bundles YAML 里,改公共 row 后两条入口都能观察到变化。
rc.1 组合已收敛。
Tether.Bundles的BundleCatalogs.Create(...)是唯一 catalog authority;Headless 与 CLI 的发布验收都从compositions/bundles/解析 stable row id、 resolved roster 和 config source。
目录装配
宿主把运行期值交给 Bundles 的唯一 catalog factory,这是YAML 组合与加载里名字到工厂那一步的真实样子:
var catalog = BundleCatalogs.Create(new BundleRuntimeOptions { Llm = new ReplayLlm() });
await using var boot = await AppBoot.StartAsync(
catalog,
new BootOptions { Composition = "compositions/bundles/headless.yaml" });
BundleCatalogs 内部注册所有 provider/consumer 名称;Headless 和 CLI 不再复制这份注册表。
YAML 的 config 子树仍会被反序列化并做 DataAnnotations 校验。
Home、Profile 与启动补丁
两个应用入口只收集原始参数,再交给同一个 AppBoot 解析;它们不会各自实现目录或优先级规则。
--home 省略时使用非空白 TETHER_HOME,再回退到当前用户目录下的 .tether;--profile 省略时
使用 local。profile 必须是一个单段名称,不能用 .、..、绝对路径或目录分隔符越出 home。
已存在的 home 会在任何插件挂载前收紧为仅当前用户可访问(POSIX 0700);它必须是当前用户拥有的普通目录,
符号链接或他人拥有的目录会让启动直接失败。缺失的 home 不会预先创建。
无需传 --patch,Boot 也会依次查找:
<home>/profiles/<profile>/cordis.patch.yml
<home>/cordis.patch.yml
它们不存在时不会创建任何东西;存在时必须是可读取的严格 UTF-8 patch 文件。完整优先级是
composition → profile 文件 → home 文件 → 按 argv 顺序重复出现的 --patch。显式 patch 必须存在,
相对路径按进程当前工作目录解析;最终 roster 的 Source 保留获胜 patch 的绝对路径。
例如,只对 team profile 禁用 stable id 为 echo-tool 的行:
# $TETHER_HOME/profiles/team/cordis.patch.yml
patches:
- id: echo-tool
disabled: true
dotnet run --project apps/Tether.Headless -- run --profile team
dotnet run --project apps/Tether.Cli -- --profile team
profile、home 或显式层只要有一个无法读取、解码、解析或命中合法 target,Boot 就会在任何插件挂载前 失败并报告绝对文件路径。完整 patch 格式和 stable-id 覆盖语义见 YAML 组合与加载。
canonical Bundles 组合文档
compositions/bundles/base.yaml 声明公共 rows,Headless/CLI profile 通过 stable row id
patch 替换 provider 或增加 consumer;应用下的 v0.1.yaml 与 v0.3-cli.yaml 只是兼容 wrapper:
# compositions/bundles/headless.yaml
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 } }
书写顺序不是依赖顺序
这份文档里 session-invariant 写在 invariants 之后看着像手工排序,
但插件激活时机由 Inject 声明驱动,不由行序决定。依赖未就位的插件停在 pending 等待,
详见服务与依赖注入的激活图。
启动序列本身很短:
await using var boot = await AppBoot.StartAsync(
BundleCatalogs.Create(new BundleRuntimeOptions { Llm = llm }),
new BootOptions { Composition = composition });
var context = boot.Context;
// 挂载后可以继续补充运行期注册
context.Require<IToolRegistry>().Register(
AIFunctionFactory.Create(static (string text) => $"echo:{text}", name: "echo"));
var agent = context.Require<IAgent>();
脚本化的 ReplayLlm
宿主不连真实供应商,而是提供一个实现 ILlmClient 的进程内假体,好处是快照可复现、且不需要 API key。
它注册在同一个服务键 ILlmClient 上——这正是LLM 抽象里服务 seam 的用途:换实现不动消费者。
脚本是一个 JSON 数组,每个元素一个 turn,kind 有三种:
| kind | 字段 | 行为 |
|---|---|---|
text(默认) | text | 返回一段助手文本 |
tool | callId、name、arguments | 返回一次工具调用 |
fail | code、message | 抛出对应稳定码的 LlmException |
[
{"kind":"tool","callId":"c1","name":"echo","arguments":"{\"text\":\"ping\"}"},
{"kind":"text","text":"done"}
]
两个实现细节值得留意:
- 脚本用尽后再请求会抛
EMPTY_RESPONSE,因此”多要了一轮”这种 bug 会立刻暴露而不是静默挂住; - 工具调用把原样参数放在
LlmContentProperties.RawToolArguments附加属性上,与真实适配器保留原样 JSON 的做法一致。
它只实现流式路径,GetResponseAsync 直接抛 NotSupportedException——非流式不在复刻范围内。
两个命令
# 默认:跑一次 Agent 交互
dotnet run --project apps/Tether.Headless -- run \
--composition apps/Tether.Headless/compositions/v0.1.yaml \
--replies apps/Tether.Headless/replies/tools.json \
--send "hello"
# 热重载自检
dotnet run --project apps/Tether.Headless -- hmr --directory /tmp/hmr-probe
run 的开关:
| 开关 | 默认 | 作用 |
|---|---|---|
--composition | 输出目录下的 compositions/bundles/headless.yaml | 组合文档路径 |
--home | 非空白 TETHER_HOME,否则 ~/.tether | 用户状态与自动补丁根目录 |
--profile | local | 自动补丁使用的单段 profile 名称 |
--patch | 无 | 必须存在的显式补丁文件;可重复,按 argv 顺序覆盖 |
--replies | 无(脚本为空) | ReplayLlm 脚本路径 |
--send | hello | 发给 Agent 的用户输入 |
--action | send | 见下表 |
--action 选择这次要验证的行为:
| 值 | 验证什么 |
|---|---|
send | 正常一次 SendAsync |
cancel | 发送后立即 CancelAsync(User),验证取消路径 |
fail-tool-result | 故意写入一条无前置 tool/call 的 tool/result,验证不变量确实会拦 |
fail-tool-result 这条很有意思:它直接往会话里追加违约事件,预期是运行期不变量里的会话检查抛 InvariantError。也就是说这个宿主不只验证”正常流程能跑通”,还验证”违约确实被拦住”。
JSON 快照输出
run 结束后打印一份结构化快照,字段是刻意选的——都是能稳定断言的东西:
| 字段 | 内容 |
|---|---|
output | 最终助手文本,取消时为 [canceled] |
error | 失败描述;InvariantError 会带上包名与 INVARIANT 码 |
status | 结束时的 AgentStatus |
notifications | 观察到的 agent/status 迁移序列 |
events | 会话日志里每条事件的 type 与 seq |
header | 最后一条 request/header 的原因、provider、model 与系统提示词 |
events 只取类型与序列号,不取载荷——这样断言的是事件形状与顺序,不会因为文本措辞变化而脆断。header 则让”这次到底发了什么给模型”变得可验证,对应会话与事件溯源里 request/header 的设计意图。
退出码是 0 或 2(有 error 时为 2),便于 CI 直接判定。
与 CI 的关系
仓库的 CI 在 dotnet test 之后会 dotnet publish 这个宿主,因此它必须始终可构建、可运行。把端到端验收放在一个真实进程入口里,而不是只靠单元测试,是为了让”插件树能被真正装配起来”这件事本身受到保护。
启动失败与诊断
初次启动逐行尝试挂载插件,等待依赖图稳定后再审计。可选行失败只向 stderr 输出 警告,成功的兄弟插件继续工作;启用的必需行失败或仍缺少服务时,Boot 回收整棵树, 不会发布 Ready。全局清单只检查实际存在且启用的 stable id:
- agent-loop;
- web-server、web-client-registry、web-client-vendored、web-frontend(含连接路由);
- cli、sdk-adapter、acp(组合没有该行时忽略)。
独立 tether-cordis 使用自己的清单 console,不继承 agent 的要求。
| 失败模式 | 可选行初次启动 | 必需行初次启动 | 后续配置更新 |
|---|---|---|---|
| 根配置或必需 patch 缺失、不可读、结构无效 | 拒绝启动 | 拒绝启动 | 拒绝无效候选,保留当前组合 |
| 插件不存在或工厂求值失败 | 警告并继续 | 回收并拒绝启动 | 事务拒绝,保留或恢复旧组合 |
| 插件配置校验失败 | 警告并继续 | 回收并拒绝启动 | 事务拒绝,保留旧配置 |
| config 中的 !!js 表达式 | 不执行 JavaScript;配置绑定失败时警告并继续 | 配置绑定失败时回收并拒绝启动 | 拒绝无效候选 |
| disabled 中的 !!js 表达式 | 不执行表达式;无效布尔值拒绝根配置,不能当作 disabled | 同左 | 拒绝候选 |
| 同步 apply 抛错 | 警告并继续 | 回收并拒绝启动 | 事务回滚 |
| 异步 apply 抛错 | 等待 settled 后警告并继续 | 等待 settled 后回收并拒绝启动 | settled 后事务回滚 |
| 注入的服务缺失 | 警告,保留 pending 行 | 回收并拒绝启动 | 不重复启动审计;沿用事务更新合同 |
| HTTP 端口绑定失败 | 警告,无该端点仍继续 | 回收并拒绝启动 | 事务回滚 |
| apply 返回任务之外的未观察后台任务故障 | 进程 fatal 退出 | 进程 fatal 退出 | 同样 fatal,与行 id 无关 |
| 行不存在或明确 disabled | 忽略 | 忽略 | 不激活,不重复启动审计 |
| 可选 provider 失败,导致必需 consumer pending | 必需 consumer 使整个启动失败 | 回收并拒绝启动 | 沿用事务更新合同 |
后续 HMR 保留 Tether 的事务回滚增强,与 dsh 的部分成功更新有意不同。 默认 Cordis.Loader API 仍严格校验;AppBoot 的初次加载显式选择 best-effort,管理面继续使用严格 Loader。
tether 的 Web/CLI 入口捕获 StartupException 后退出 1,终端显示失败插件、缺失服务与 完整诊断文件位置。报告写到 TETHER_HOME/logs/startup-时间戳-UUID.log,目录权限为 0700、 文件为 0600(Windows 使用对应私有 ACL),只创建新文件,不覆盖或自动删除旧报告。 正文第一行提醒原始错误可能包含配置或凭据,分享前应检查;报告不脱敏,也不额外读取环境变量或配置正文。 写文件失败时,写错误和完整诊断回退到 stderr。只有可选失败时不生成报告文件。 tether-cordis 使用同一审计与终端简报,但不写 Tether home 下的诊断文件。
.NET 的未观察任务通知在故障 Task 被 GC 回收后触发,不能承诺 Node 的即时 rejection checkpoint 时机;这项运行时差异应与失败矩阵一起理解。收到通知后,宿主只报告一次, 清理最多等待两秒,随后退出 1;清理失败或同步阻塞不能阻止退出。插件应返回或 await 自己启动的任务,以便普通 apply 故障直接进入启动审计。