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返回一段助手文本
toolcallId、name、arguments返回一次工具调用
failcode、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用户状态与自动补丁根目录
--profilelocal自动补丁使用的单段 profile 名称
--patch无必须存在的显式补丁文件;可重复,按 argv 顺序覆盖
--replies无(脚本为空)ReplayLlm 脚本路径
--sendhello发给 Agent 的用户输入
--actionsend见下表

--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 故障直接进入启动审计。

下一步

在 GitHub 上编辑此页