第 12 周 · Tether 源码课

Tools Safety:从模型提议到可持久化结果

预计 100–120 分钟 先修:第 6 周:Waterfall 与异常语义、第 9 周:Session Events、第 11 周:LLM typed updates

学完你能做到

  • 从 FunctionCallContent 追踪到 tool/call 与 tool/result
  • 区分 schema validation、approval、policy、sandbox 与 invariant
  • 解释 fail-closed、stable failure code 与 model-order publication
  • 用 ToolPipelineTests 验证安全闸门早于 durable result handoff

课程进度

  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 周

一句话先懂

模型只能提议一次 tool call;是否可见、是否获准、怎样执行、结果能否交给 Session,都由确定性代码逐关决定。

ToolRegistry 先保存 call identity 与原始 arguments,再经过 pre policy(其中的 Ask 决策会请求 approval)、monotonic guards、execute、post policy 和 awaited result invariants,最后才把 outcome 交给 durable sink。

LLM 输出 {"name":"fs_write", ...} 不等于文件已经写了。把提议直接反射调用函数,会把概率性输出变成未经审查的副作用。Tether 把每个阶段拆开,让拒绝、取消、失败和审计都有稳定落点。

先看安全闸门

Model proposal name · callId · raw JSON Registry + parse visible tool? valid JSON? Durable declaration tool/call body 开始前留证 Pre waterfall Allow · Deny · Ask Guards / Approval fail closed Execute waterfall tool body success / stable failure Post waterfall Accept · Replace · Block Result policies await invariants correlation / type Durable outcome tool/result Contained observers telemetry / UI 并行只改变 body overlap,不改变模型可见顺序 calls: A ──────┐ B ─┐ C ───┐ outcomes: A ──────▶ B ─▶ C ───▶ 仍按模型声明顺序
拒绝与失败也是 outcome;真正危险的是跳过记录、校验或 terminal cleanup。

一个类比:门禁不等于保险柜

一次 tool call 像访客申请进入实验室:登记身份、检查申请格式、根据规则决定是否询问负责人、刷门禁、执行获准操作、离场前核对记录。

但每道关卡解决不同问题:

  • JSON schema 类似“申请表字段齐全”,不能证明操作安全。
  • Approval 类似“某人同意这次操作”,不能限制进门后能碰什么。
  • Sandbox 类似物理隔离与权限边界,即使获批也不能越界。
  • Invariant 类似账目核对,拒绝把错配或非法 shape 当成有效结果。

类比边界:Tool pipeline 还有 scope visibility、Waterfall wrappers、bounded parallelism、model-order sink 与 cancellation drain。门禁类比只帮助区分安全职责,不能替代具体执行契约。

从模型内容到 ToolCall

Agent 从 ChatResponseUpdate 收集 FunctionCallContent,完整 assistant/message commit 后,提取:

callId + name + exact raw arguments JSON + AgentPipelineScope

保留 raw JSON 很重要:解析失败也要能精确说明模型实际给了什么。ToolCall 构造只固定 identity;参数能否解析、tool 是否可见由 ToolRegistry 下一阶段判断。

Agent 提供 SessionToolBatchSink:每个 call 在 body 之前追加 tool/call,每个 final outcome 通过 result policy 后再追加 tool/result。因此崩溃恢复能区分“未开始”和“已声明但结果未知”。

每道关卡的精确职责

阅读 src/Tether.Core.Tools/ToolRegistry.cs:

  1. Resolve + parse:未知 name、invalid JSON、参数绑定失败都变成稳定 ToolFailureCode,不让模型输入成为未分类 exception。
  2. Pre waterfall:返回 Allow、Deny 或 Ask;不调用 next() 可短路后续策略。
  3. Approval:Ask 时若 approval provider / session / agent / owner 任一缺失,直接以 DENIED fail closed;provider 在场但 answerer 不可用或返回 Unavailable 时以 UNAVAILABLE 关闭——都不默认放行。
  4. Monotonic guards:任一 ancestor scope 拒绝都不能被 child scope 再“批准回来”;对 Allow 与获批的 Ask 都生效。
  5. Execute waterfall:wrapper 可控制 cancellation、短路 body 或替换 outcome;body exception 被规范化。
  6. Post waterfall:对 settled outcome Accept、Replace 或 Block。
  7. Awaited result policy:在 durable sink 与 telemetry 前验证最终 candidate;失败直接拒绝 handoff。
  8. Result observer:结果已交付后的 contained notification;observer failure 不反写 durable result。

ToolsInvariantPlugin 在第 7 关检查 execution name/callId 非空且 outcome callId 与 execution 一致。能力包还可注册更具体的 typed-result invariants。

Stable failure 也是给模型的结果

Code典型原因body 是否可能运行
UNKNOWN_TOOLscope 内没有这个 name否
INVALID_ARGSraw JSON 或参数绑定非法否
DENIED / UNAVAILABLEpolicy、guard、approval fail closed否
TOOL_ERRORbody 或 pipeline stage 失败视阶段而定
ABORTED_BEFORE_DISPATCHbody 前取消否
ABORTEDdispatch 后取消可能已开始
BLOCKEDpost policy 拒绝 settled result是

失败 outcome 仍保留 callId,并可投影成 FunctionResultContent 给下一次模型请求。模型因此能看到“工具不存在”并调整,而不是让整个 Agent 因普通工具错误丢失 turn 边界。

并发采用 fail-closed 分类

只有 registration 的 IsConcurrencySafe(arguments) 明确返回 true,call 才进入 parallel group;缺失、返回 false 或 classifier 抛错都归为 Exclusive。Exclusive call 是组间 barrier。

parallel bodies 可完成得乱序,但 final outcomes 按模型 declaration order 交给 sink。这样 session 的 tool/result 顺序不依赖机器调度运气。

动手实验:安全规则必须早于 durable result

实验目标:比较成功、未知工具、Ask 无 approval、post block 与 invariant failure,判断 body、sink 和 observer 各自是否运行。

1. 先读两份 Replay script

最小 call/result 闭环 apps/Tether.Headless/replies/tools.json:

[
  {"kind":"tool","callId":"c1","name":"echo","arguments":"{\"text\":\"ping\"}"},
  {"kind":"text","text":"done"}
]

失败样例 apps/Tether.Headless/replies/workspace-failure-linux.json 则声明 write failure、shell timeout 与非零 exit,最后仍给一段 text。只阅读 fixture,不在课程仓库直接执行写文件的 rows。

预测:这条闭环需要几个 LLM steps?无论 body 成功还是产生 failure outcome,c1 在 tool/call 与 tool/result 中都应保持什么关系?

2. 运行工具测试

dotnet test tests/Tether.Core.Tools.Tests/Tether.Core.Tools.Tests.csproj \
  --filter "FullyQualifiedName~ToolPipelineTests|FullyQualifiedName~ToolSchedulerTests"

3. 重点观察五个断言

在 ToolPipelineTests.cs 中定位:

  • Unknown_invalid_and_throwing_calls_have_stable_failures
  • Pre_waterfall_short_circuits_and_ask_fails_closed_without_approval
  • Post_waterfall_replaces_or_blocks_the_settled_value
  • Result_policy_failure_is_awaited_before_sink_and_telemetry
  • Result_policy_failure_runs_terminal_cleanup_for_every_dispatched_call

最后两项说明 invariant failure 的顺序:sink 尚未收到 outcome,telemetry 也未收到;但已经 dispatch 的 companion bookkeeping 必须对每个 call 做 terminal cleanup,不能因为首个 policy error 就遗留“仍在执行”的假状态。

4. 解释

安全失败不是“什么都没发生”。call declaration 可能已经 durable,body 也可能已产生副作用;系统必须用准确 outcome 或显式拒绝关闭它的生命周期。越靠后的 failure,越不能假定 body 从未运行。

检查理解

1. 用户点击批准,是否表示 tool 不再需要 sandbox?

查看答案

不表示。Approval 表达一次意图决策;Sandbox 限制进程实际可触达的资源。获批操作仍可能被错误实现、提示注入或参数扩大影响,二者必须分层。

2. arguments 通过 JSON schema,是否能说明副作用安全?

查看答案

不能。Schema 只验证数据 shape,例如 path 是字符串;它不判断 path 是否越界、命令是否危险、操作是否幂等。policy、Provider validation 与 sandbox 负责其他边界。

3. 两个 parallel tool 中 B 先完成,能否先把 B 的 tool/result 写进 Session?

查看答案

不能按完成顺序随意写。scheduler 可以让 body overlap,但 sink publication 固定为模型声明顺序,保证可重放日志不依赖调度时序。

本周带走

  • 模型只提议 call;registry、policy、approval、body、post 与 invariant 分别拥有明确安全职责。
  • tool/call 在 body 前 durable,tool/result 在 awaited result policies 通过后交付。
  • Schema、approval 与 sandbox 不互相替代;越靠后失败,越要保守判断副作用是否已经发生。

下一周把 Session、LLM 与 Tools 放回 Agent loop,解释何时继续 step、何时关闭 turn。完整管线可查工具和运行期不变量。

在 GitHub 上编辑此页