第 12 周 · Tether 源码课
Tools Safety:从模型提议到可持久化结果
学完你能做到
- 从 FunctionCallContent 追踪到 tool/call 与 tool/result
- 区分 schema validation、approval、policy、sandbox 与 invariant
- 解释 fail-closed、stable failure code 与 model-order publication
- 用 ToolPipelineTests 验证安全闸门早于 durable result handoff
课程进度
- 第 1 周
- 第 2 周
- 第 3 周
- 第 4 周
- 第 5 周
- 第 6 周
- 第 7 周
- 第 8 周
- 第 9 周
- 第 10 周
- 第 11 周
- 第 12 周
- 第 13 周
- 第 14 周
- 第 15 周
- 第 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 把每个阶段拆开,让拒绝、取消、失败和审计都有稳定落点。
先看安全闸门
一个类比:门禁不等于保险柜
一次 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:
- Resolve + parse:未知 name、invalid JSON、参数绑定失败都变成稳定
ToolFailureCode,不让模型输入成为未分类 exception。 - Pre waterfall:返回
Allow、Deny或Ask;不调用next()可短路后续策略。 - Approval:
Ask时若 approval provider / session / agent / owner 任一缺失,直接以DENIEDfail closed;provider 在场但 answerer 不可用或返回Unavailable时以UNAVAILABLE关闭——都不默认放行。 - Monotonic guards:任一 ancestor scope 拒绝都不能被 child scope 再“批准回来”;对
Allow与获批的Ask都生效。 - Execute waterfall:wrapper 可控制 cancellation、短路 body 或替换 outcome;body exception 被规范化。
- Post waterfall:对 settled outcome
Accept、Replace或Block。 - Awaited result policy:在 durable sink 与 telemetry 前验证最终 candidate;失败直接拒绝 handoff。
- Result observer:结果已交付后的 contained notification;observer failure 不反写 durable result。
ToolsInvariantPlugin 在第 7 关检查 execution name/callId 非空且 outcome callId 与 execution 一致。能力包还可注册更具体的 typed-result invariants。
Stable failure 也是给模型的结果
| Code | 典型原因 | body 是否可能运行 |
|---|---|---|
UNKNOWN_TOOL | scope 内没有这个 name | 否 |
INVALID_ARGS | raw JSON 或参数绑定非法 | 否 |
DENIED / UNAVAILABLE | policy、guard、approval fail closed | 否 |
TOOL_ERROR | body 或 pipeline stage 失败 | 视阶段而定 |
ABORTED_BEFORE_DISPATCH | body 前取消 | 否 |
ABORTED | dispatch 后取消 | 可能已开始 |
BLOCKED | post 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_failuresPre_waterfall_short_circuits_and_ask_fails_closed_without_approvalPost_waterfall_replaces_or_blocks_the_settled_valueResult_policy_failure_is_awaited_before_sink_and_telemetryResult_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。完整管线可查工具和运行期不变量。