本文承接第 4 篇的 tool/call -> tool/result -> next-step,也承接第 5 篇关于 Session、checkpoint 与恢复的结论。前两篇回答"工具结果怎样让任务继续"和"这些事实怎样留下来";这一篇只追一个更危险的问题:模型提出一次工具调用之后,系统凭什么让它真的发生?

设想一个部署助手收到任务:"把 orders 服务重启。"模型很快就能生成一段工具调用:

{ "name": "bash", "arguments": { "command": "kubectl rollout restart deploy/orders" } }

这只是模型的意图,不是操作已经被授权,更不是命令已经在机器上执行。一个生产级 Agent Runtime 至少还要回答:

执行前,系统必须回答的三个问题:

  • 这个工具是否暴露给该 Agent?——工具可见性
  • 参数是否合法?——参数校验
  • 当前策略是否允许?是否需要用户同意?——执行策略与审批

执行中及执行后,系统必须回答的两个问题:

  • 若获准,进程到底能写哪些文件?——资源隔离
  • 进程在命令刚执行后崩溃,恢复时如何避免"既不知道是否执行过,又直接重试一次"?——可恢复性

这五问分属两个阶段:前三问决定"能否开始",后两问决定"如何执行"和"如何恢复"。它们在 DSH 中被拆解到 Tool Runtime、Approval、Sandbox 与 Session checkpoint 四组机制里——这个拆分,是本篇最值得迁移的工程思想。

在逐节展开之前,先用一张速查表看清完整治理链路的九个环节。 后续各节会逐一展开每个阶段,但先看到全貌,才不会在细节中丢失方向。

九层治理链路速查:

层级核心职责一句话概括
1. 工具可见性控制模型能选择哪些工具可见性 ≠ 授权
2. 参数校验模型传参是否合法不校验就不进入审批
3. pre-execute 策略这次调用是否允许allow / deny / ask
4. 单调 guard守住不可退让的底线拒绝后不能被重新放行
5. 一次性审批需要外部确认才能继续缺失即拒绝,不默认放行
6. checkpoint 耐久化副作用前先留痕不持久则不执行
7. sandbox 资源约束实际能碰什么范围prompt 是说明,backend 才是 enforcement
8. post-execute 结果治理检查、阻断或改写结果改 content ≠ 改 canonical
9. 有序提交并发执行、顺序落库并发 body,顺序提交

这也是阅读本篇的索引:第 3 节解释第一层,第 4 节解释第二至第四层,第 5 节解释第六层及其升权,第 6 节解释第五层,第 7 节收束最后三层。第 2 篇的装配与第 3 篇的插件生命周期决定这些层是否存在;第 4、5 篇则分别提供最后一层的调度顺序和 checkpoint 所依赖的持久化真源。


1. 先校正概念:四件事不是同一件事

很多 Agent 的实现会把“模型看得见工具”“用户点了允许”“进程不能写工作区外文件”统称为权限。这样写 demo 没问题,但排查一次越权或恢复事故时就会立刻失去抓手。

DSH 中,这四件事的归属不同:

问题DSH 的主要机制它控制的对象保证什么
模型能否选择一个工具?工具注册、Agent scoped visibility、ctx.tools.restrict()请求中呈现哪些 tool schema不阻止已知调用绕过“不可见”进入运行时
某一次调用是否应当执行?tools/pre-executectx.tools.guard()ask这次调用的允许/拒绝/询问决策不把进程限制在文件系统白名单里
用户是否同意一次危险动作?ctx.approval.request()approval/request一次性 allowed-once / 拒绝 / 取消不是长期权限令牌,也不是 UI 本身
获准的进程实际能碰什么资源?ctx.sandbox 的 sandbox backend文件副作用的实际约束不替代工具调用策略;也不等于网络隔离
外部副作用前是否留下可恢复事实?session-checkpoint-policy模型请求、顶层工具 body 前的耐久屏障不承诺每个流式 chunk 都同步落盘

尤其要记住一条容易犯错的结论:

ctx.tools.restrict() 是 Agent 视角的工具可见性/呈现控制,不是安全授权边界。

如果把“从模型的工具列表里隐藏 bash”当作安全措施,那么一个来自重放、协议接入或错误实现的 bash 调用会直接暴露设计漏洞。真正的执行决策必须落在 pre-execute 与不可被后续放行的 guard 上;真正的资源隔离还要由 sandbox backend 执行。

这也把前三篇串起来了:第 2 篇讲 Profile/Bundle 决定哪些插件被装配,第 3 篇讲 Cordis 把能力放在可替换的 service seam 上。到了这里可以看到它们的落点:同一个运行可以装配工具消费者、审批回答者、sandbox provider、持久化与 checkpoint policy;少装一个,行为会改变,而且应当由机制明确地暴露出来,而不是静默“默认允许”。

模型产生 tool call

工具 schema 与参数校验

pre-execute 策略
allow / deny / ask

一次性审批

不可逆 guard

checkpoint:调用事实先耐久

sandbox backend 约束资源

工具 body 产生副作用

结果处理与实时通知

Agent Loop 追加 Session tool/result
并把 additionalContexts 入队

图中的线不是每次都完整经过。例如普通只读工具可以不走 ask;参数无效会在更早处停止;未装审批服务时需要审批的调用会被拒绝。但把完整路径先看清,后面的分支才不会混乱。

图中的九层对应引言速查表中的前八层——从“模型产生 tool call”到“结果处理与实时通知”,最后“Agent Loop 追加 Session tool/result 并入队”对应第九层“有序提交”。后文各节将按这个顺序逐层展开。

SOURCEpackages/core/tools/README.md 定义 Tool Runtime 的完整流水线;packages/interaction/user-approval/README.zh.md 定义一次性审批与审计;packages/sandbox/sandbox/README.zh.md 定义 sandbox service 的词汇和 fail-closed 行为。


2. 工具调用的“执行”与“记录”:为什么需要分开?

本节回答一个具体问题:工具的实际执行和日志记录,为什么不是同一个系统完成的?

2.1 第 4 篇追到了哪里?

第 4 篇已经追到:模型输出 tool-call 后,Agent Loop 从 assistant message 中取出多个 tool-call block,executeToolCalls() 先把每个待执行调用以 tool/call 写入 Session,再交给 ctx.tools 调度。完成后,Loop 按模型原始顺序追加 tool/result,并把工具携带的 additionalContexts 交给 Inbox,等待同一 Turn 的下一 Step 领取。

但第 4 篇没有展开的一个细节是:“记录日志”和“执行工具”是两套不同的系统完成的。

  • Agent Loop 负责写 Session(tool/calltool/result
  • Tool Runtime 负责实际执行(参数校验、策略、审批、sandbox、body)

两者不能合并,因为职责不同。

2.2 两个“结果”的区别

InboxSandbox/Tool bodyApprovalTool RuntimeSessionAgent LoopInboxSandbox/Tool bodyApprovalTool RuntimeSessionAgent Loopopt[需要审批]append(tool/call)prepare / dispatch / finalize参数校验、pre-execute、guardrequest(本次 tool/call)allowed-once / rejected / cancelled / unavailableflush(顶层 body 前,由 checkpoint policy)在有效 sandbox policy 下执行canonical resultpost-execute、finalizeContentToolExecutionResultemit tools/result(实时观察)append(tool/result)additionalContexts -->> next-step

图里有两个听起来很像的东西:

名称谁产生实时通知?持久事实?用途
tools/resultTool RuntimeUI/指标/日志插件实时观察
tool/resultAgent Loop模型下次请求看到、Session 恢复重放

tools/result:Tool Runtime 执行完工具后发出的实时通知。指标、界面或日志插件可以旁观它。observer 抛错不应改写工具结果。

tool/result:Agent Loop 写进 Session 的持久事件。模型下次请求会看到它,进程恢复会用到它。第 5 篇的“模型可见内容必须能从日志重建”在这里落地。

2.3 为什么不能让工具自己写 tool/result

一个直观的疑问:既然 Tool Runtime 执行完了工具,为什么不直接让它把结果写进 Session?

因为并行调用时,哪个工具先执行完是不确定的。

模型可能同时调用了三个工具:A、B、C。执行顺序可能 B 先结束、A 第二、C 最后。但模型期望的顺序是 A、B、C——这是模型调用时的顺序。

如果每个工具执行完就立即自己写 tool/result,Session 里的顺序会变成 B、A、C,与模型期望的顺序不一致。回放时 transcript 的顺序就乱了。

所以设计是:

阶段负责者顺序要求
工具 body 执行Tool Runtime可并发
tool/result 提交Agent Loop按原始 tool-call 顺序

executeToolCalls() 让 body 并发执行以提升吞吐,但结果提交由 Agent Loop 按原始顺序保证(commitReady())。这样既得到并发吞吐,又保证 transcript 可确定地重放。

第 4 篇的“并发执行、顺序提交”不是调度细节,而是第 5 篇可恢复日志的前提。

2.4 本节小结

问题答案
日志和执行是一套系统吗?不是。Agent Loop 写日志,Tool Runtime 做执行
两个“结果”的区别是什么?tools/result 是实时通知(给 UI/指标),tool/result 是持久事实(给模型/恢复)
为什么不让工具自己写日志?并发执行时,结束顺序 ≠ 调用顺序;必须由 Agent Loop 按原始顺序提交

SOURCEpackages/core/agent-loop/src/tool-calls.tsappendToolCall()appendToolResult()commitReady()packages/core/tools/src/index.tsnotifyResult()


3. 工具首先是一份可验证的声明,而不是一个 execute() 函数

第 4 篇的 executeToolCalls() 可以把 assistant message 中的调用交给 ctx.tools,前提是这里已经有一份能解释参数、结果和执行方式的工具声明。本节展开的正是这个前提:没有 schema 的“函数调用”无法成为可审计的模型输入。

3.1 为什么工具不能只是一个函数?

在普通项目里,一个工具就是函数:

const readFile = async (path: string) => fs.readFile(path, 'utf8')

这没问题——程序员知道怎么调用它。但模型调用工具的方式完全不同:模型发来的是 JSON 字符串,系统需要知道参数类型、格式、输出结构。

没有 schema 的“函数”对模型调用方来说是黑盒。

DSH 要求工具插件向 ctx.tools.register() 注册时,必须包含两样东西:

  1. 输入 schema:参数叫什么、什么类型、是否必填
  2. 输出声明:结果的结构是什么样的

这套声明的核心目的是:让工具调用成为一份可验证、可审计的记录,而不是一次不可追溯的函数执行。

3.2 工具结果为什么分三层

工具执行完,返回的数据被三类消费者同时需要:

消费者需要什么如果混在一起会怎样
模型适合推理的精简信息看到 UI 数据,token 浪费,推理失效
UI富展示内容(颜色、格式)模型看到 UI 文案,以为那是真实数据
审计/策略可验证的结构化数据只看文本,不知道真实返回值

DSH 的做法是把它们分开:

字段给谁用内容
canonical value程序、策略、审计结构化真实结果(如 { temperature: 25 }
content模型精简文本描述(如 "北京今天晴,25°C"
metaUI展示数据(颜色、图标、格式)

这样模型看到适合推理的内容,审计看到可验证的结构,UI 看到可展示的数据——互不干扰。

3.3 为什么参数校验必须早于审批?

模型传来的 JSON 可能不合法。比如工具要求 pathstring,模型传了 123

DSH 的顺序是:先校验参数,再决定是否审批。

如果反过来——先审批、再校验,会发生什么?

用户收到一个审批请求:“批准执行 bash,参数 rm -rf /”。用户点了“批准”。然后系统发现参数非法,什么也没执行。

事后审计看到:

  • 审批记录:用户批准了 rm -rf /
  • 执行记录:什么都没发生

用户到底批准了什么? 他以为自己批准了危险操作,但系统压根没执行。审计无法对齐“用户同意的内容”和“系统实际做的内容”。

审计歧义的本质是:用户批准时的语义 ≠ 系统执行时的语义。 即使事后能通过 seq 查到不一致,这个“不一致”本身已经发生了——用户以为自己在批准一件事,系统做的是另一件。

正确的顺序是:先校验,确认调用合法,再交给审批。 这样用户批准的每一个请求都是“可执行、可描述”的合法动作,事后审计时审批记录和执行记录完全匹配。

3.4 restrict() 为什么不是安全机制?

restrict() 控制的是:模型能看到哪些工具

它适合做:

  • 子 Agent 只看到检索工具,不看到部署工具
  • Plan 模式只展示无副作用工具

它不适合做:

  • 防止危险调用被执行

原因很简单:调用可以绕过模型。

调用来源是否经过模型?restrict() 能拦住吗?
模型在对话中调用
历史重放一个调用不能
API 直接提交 tool-call不能
另一个 consumer 直接调用不能

restrict() 管的是“模型能建议什么”。pre-execute/guard 管的是“系统能执行什么”。 前者发生在模型层,后者发生在执行层。如果把 restrict() 当成安全边界,相当于把门锁装在“建议”上,而不是装在“门”上。

3.5 本节小结

问题答案
工具在 DSH 中是什么?一份可验证的声明,包含 schema + 输出规范 + 元数据
结果为什么要分三层?模型、UI、审计三类消费者需求不同,混在一起会互相污染
参数校验为什么在审批之前?保证用户批准的每一个请求都是“可描述、可执行”的合法动作
restrict() 为什么不算安全机制?它管的是“模型能建议什么”,不是“系统能执行什么”——调用可以绕过模型

SOURCEpackages/core/tools/README.md 的 registration、canonical result、scoped restrict 与 pipeline 说明;packages/core/tools/src/index.ts 的 execution scheduler。


4. 执行前策略:allow、deny、ask

上一节回答了“工具是什么”(一份可验证的声明)。本节回答:声明合法之后,系统凭什么决定让不让它执行?

4.1 三类决策:allow / deny / ask

工具真正进入 body 前,先经过 tools/pre-execute waterfall。它输出三类决策:

决策含义后续发生什么
allow这次调用可继续进入 guard、sandbox、执行 body
deny(reason)这次调用被拒绝生成带原因的错误结果,不进入 body
ask(reason?)需要外部决定交给审批服务,等待用户/管理员确认

注意allow 不代表“这个工具永远安全”,只代表“这一层策略没有拒绝这一次调用”。策略可以按工具名、参数、当前 session 状态或业务规则动态决定——读公开文档 allow,执行 kubectl ask,删生产库 deny。

这是三层而不是两层的原因是:有些动作系统自己不能判断,需要外部输入(如人工审批)。ask 正是为此存在。

4.2 ask:缺失即拒绝,不默认放行

ask 的语义是“需要外部回答才能继续”,不是“显示一个提示,没人回应就默认放行”。

如果审批通道不可用(UI 没弹出来、服务没装、没人应答),系统应该怎么办?

ask → 请求审批
  ├── allowed-once → 继续执行
  ├── rejected → 拒绝
  ├── cancelled → 拒绝
  ├── unavailable / 无审批服务 → 拒绝
  └── 审计记录写失败 → 拒绝(不给未记录决定)

只有 allowed-once 这一条路径能继续执行。 其他所有路径(拒绝、取消、服务不可用、审计失败)都映射为拒绝。

审计要求:每次审批请求都会追加 approval/askedapproval/decided 两条事件。如果这对审计记录在提交前失败,整个请求会失败,而不是制造一个“已允许”但无法追溯的记录。

模型不会看到权限 UI 的内容。模型只会看到最终写进 Session 的工具结果——允许则看到正常结果,拒绝则看到错误结果。

4.3 guard:单调的底线拦截器

除了可组合的 pre-execute 策略,Tool Runtime 还支持 ctx.tools.guard()。它在 pre-execute 之后运行,并具有 单调性

一个 guard 拒绝后,后续 waterfall 不能再把它重新放行为 allow

为什么需要 guard?

pre-execute listener 是可替换、可组合的部署策略。但因为可组合,就存在一个问题:后安装的“允许”插件可能意外覆盖前面“拒绝”插件的决定。

guard 解决的是策略所有权问题:

  • pre-execute:适合可替换的部署策略
  • guard:适合拥有不可退让底线的组件,如“某类凭证绝不能通过这个工具发送出去”

如果没有单调约束,后安装的通用“允许”插件可能覆盖安全插件的拒绝。

注意:guard 不等于操作系统隔离。它仍在决定“要不要调用工具”,与 sandbox(决定“调用后能碰什么资源”)是前后相邻、职责不同的两层。

层级职责
pre-execute可组合的策略决策(allow/deny/ask)
guard不可退让的单调底线
sandbox已获准调用的资源约束

4.4 本节小结

问题答案
pre-execute 输出什么?三类:allow、deny、ask
ask 的语义是什么?“需要外部决定才能继续”,不是“先试试”
审批通道不可用时怎么办?拒绝——fail-closed,缺失即拒绝
guard 和 pre-execute 的区别?guard 是单调的,拒绝后不能被重新放行;pre-execute 是可组合的
guard 和 sandbox 的区别?guard 决定“要不要调用”,sandbox 决定“调用后能碰什么资源”

SOURCEpackages/core/tools/README.mdtools/pre-executeguard()ask 和 fail-closed 描述;packages/interaction/user-approval/README.zh.md 的 approval outcome、turn 归属与审计对。


5. 用户审批与沙箱升权:批准的是这一次,不是把门拆掉

前面两节解决了“调用应不应该开始”(第3、4节)。本节回答紧随其后的两个问题:开始后的进程实际能影响哪里?如果默认权限不够,模型能不能临时申请更高的权限?

先理解一个基础概念:sandbox 是执行期的资源隔离层。

pre-execute 和 guard 决定的是“要不要调用工具”——它们在调用发生之前做出判断。sandbox 处理的是另一个问题:调用已经获准,但实际执行时,进程能碰哪些文件、能写哪些目录?

这是两层不同的职责。前一层管“入口”,后一层管“边界”。即使调用被允许了,sandbox 仍然可以把它的活动限制在特定范围内。这也是为什么不能把 restrict() 当作安全边界——sandbox 才是真正执行资源约束的地方。

本节讨论的是 sandbox 的文件副作用约束,不是网络隔离、凭证控制或所有系统资源的万能隔离。DSH 的 sandbox 词汇在源码中明确定义为 file effects 的范围。

5.1 sandbox 的三个 mode

DSH 定义了三个针对文件操作的 mode:

mode文件副作用含义典型场景
read-only限制写入;可有面向宿主的必要输出 sink代码分析、日志查看、文档检索
workspace-write可写 session workspace 根目录,以及后端定义的临时区域修改项目代码、创建配置文件
danger-full-accesssandbox 不再约束可用操作的文件修改系统维护、紧急修复

两个重要限定:

第一,这三个 mode 只针对文件操作。不能把 danger-full-access 宣传成“所有系统资源都被隔离”——它只管文件,不管网络、凭证或进程。

第二,sandbox mode 是实际执行的策略,不是给模型的建议。如果对应的 sandbox backend 不可用,系统会明确拒绝执行并报错,而不是悄悄降低安全标准来“让任务跑通”。

5.2 prompt 是说明,不是 enforcement

sandbox-policy 会把当前文件策略作为 system prompt context 提供给模型。模型知道“当前是 read-only,如果需要写文件该如何申请升权”。这改善了模型行为,减少了无意义的失败调用。

但 prompt 是说明,不是强制执行。模型可能忽略它,或者调用根本不来自模型(历史重放、API 直接调用)。安全性始终取决于 sandbox backend 在执行时实际施加的限制。

这形成了一个清晰的分工:

工具列表 / Prompt:影响模型的选择与预期 —— 但不强制
pre-execute / guard:决定这次调用是否可以开始 —— 入口控制
sandbox backend:实际执行时限制资源范围 —— 真正的约束

三者协作,任何一个都不能替代另一个。restrict() 不替代 sandbox,prompt 也不替代 sandbox。

5.3 升权:临时申请更高权限,不是永久授权

模型在调用参数中可以提出 sandbox_permissionsjustification,申请比当前默认模式更高的权限。升权不是永久改变 Session 的默认模式,而是为这一次调用请求一个严格更宽的 mode。

升权规则是单向严格递增的:

read-only        → 可申请 workspace-write 或 danger-full-access
workspace-write  → 可申请 danger-full-access
danger-full-access → 不存在更宽目标

两个必要条件:

  1. 请求的 mode 必须严格更宽(read-only → workspace-write 合法;workspace-write → read-only 不合法)
  2. 必须带非空 justification(理由)

只给 mode 不给理由、只给理由不请求 mode,都是无效参数,不会触发审批。

检查通过后,经由 approval channel 请求一次许可。拒绝、取消、无人应答、无 Agent 可审计,都会在工具真正执行前失败。只有 allowed-once 才能继续执行。

Sandbox backendApprovalBash/FS tool模型Sandbox backendApprovalBash/FS tool模型alt[allowed-once][非允许]alt[不够宽或参数无效][合法升权]调用 + sandbox_permissions + justification校验参数配对与严格升权错误结果;不询问用户请求批准本次操作一次允许以本次获准 mode 执行rejected/cancelled/unavailable错误结果;工具 body 未运行

“一次允许”很关键:本次调用结束后,权限恢复为原来的 mode。模型下次想再升权,必须重新申请、重新审批。这避免了“一次批准变成万能通行证”的风险——不会产生一个可被后续任意命令复用的长期 token。

5.4 本节小结

问题答案
sandbox 的职责是什么?执行期资源隔离,限制已获准调用能碰哪些文件
三个 mode 分别是什么?read-only / workspace-write / danger-full-access
prompt 是安全边界吗?不是,prompt 是说明,backend 才是 enforcement
升权是永久的吗?不是,只对这一次调用有效,下次需要重新申请
升权需要什么条件?严格更宽 + 非空 justification + 一次审批

SOURCEpackages/sandbox/sandbox/src/index.tsREADME.zh.md 定义 mode、enforcement 与 unavailable;packages/sandbox/sandbox/src/escalation.ts 定义严格升权表、参数配对与 approval-before-execute 顺序;packages/sandbox/sandbox-policy 的测试验证三个 mode 的 prompt 表述。


6. 执行前 checkpoint:让意图先于副作用持久化

本节回答第 5 篇留下的问题:在工具 body 执行之前,为什么还要多一道 checkpoint 屏障?

6.1 什么是 checkpoint?

checkpoint 是把内存中的事件强制写入磁盘,使之在进程崩溃后仍然可恢复。崩溃的是 DSH Agent 主进程,不是执行命令的子进程

Session.append() 是内存操作——它快,但进程崩溃后丢失。Session.flush() 是磁盘操作——它慢,但能跨越进程边界存活。

checkpoint 在 body 执行前调用一次 flush,是为了在主进程崩溃后,仍能恢复‘曾经发起过这个调用’的意图。我们不在每个操作上都 flush,只在可能产生外部副作用的边界上做一次——用一次同步 I/O 的代价,换取崩溃后可追溯性

Session.append(tool/call) 成功
  = 内存日志已有调用事实
  ≠ 硬崩溃后一定能恢复

Session.flush() 成功
  = 持久化 listener 已完成本次耐久检查点

6.2 为什么需要 checkpoint?

假设审批已通过,sandbox 也就绪。现在 kubectl rollout restart 即将执行。

最糟糕的时序是: 命令已经对外部系统产生效果,进程却在 tool/call 还没落盘时硬崩溃。

重启后会出现一个不可判定的世界:外部服务可能已重启,Session 却没有“我准备执行什么”的记录。系统既不能安全重试,也无法解释这次变更来自哪里。

deny

allow

失败

等待中被取消

成功

tool/call 已 append

pre-execute / guard

错误结果;不 flush、不执行 body

flush Session

fail-closed;不执行 body

ABORTED_BEFORE_DISPATCH

执行顶层工具 body

6.3 checkpoint 的位置:为什么在这里?

checkpoint 放在 pre-execute / guard 之后、工具 body 之前

为什么在审批之后?

被拒绝的调用不会产生外部副作用,没必要为它建立“副作用前耐久屏障”。allow / ask / guard 全部通过后,接下来首次可能发生的外部效果正是 tool body,因此 checkpoint 放在这里刚好覆盖风险窗口。

为什么在 body 之前?

因为一旦 body 开始执行,外部世界就可能发生变化。如果这个时候还没有持久化记录,崩溃后就无法追溯意图来源。checkpoint 必须发生在 body 之前,才能保证“外部变化”和“意图记录”同时存在。

6.4 checkpoint 失败:fail-closed

如果 flush 失败,系统拒绝执行 tool body。

原因很直接:如果 flush 失败仍然执行,外部可能已经变了,日志却没有留下意图——系统又回到了那个不可判定的状态。

这个设计牺牲的是短暂可用性,保住的是可审计与可恢复性。

6.5 checkpoint 之后崩溃:TOOL_OUTCOME_UNKNOWN

checkpoint 保证的是“调用意图已经持久化”,不保证“执行结果”已经写入。

命令开始后硬崩溃,日志可能留下 tool/call,却没有 tool/result。DSH 的恢复语义会为这种未配对调用提供模型可见的 TOOL_OUTCOME_UNKNOWN

恢复后未配对的 tool/call
  → 系统明确告知模型“结果未知”
  → 模型不能盲目重试有副作用的操作
  → 应按工具幂等性、外部状态核验或用户确认来决定

这不是“完全消灭崩溃”,而是把不可避免的不确定性编码为显式结果。读操作可以重试;创建订单、重启服务、发送邮件必须先确认外部世界的状态。

6.6 和第 5 篇的关系

第 5 篇定义了 append vs flush 的耐久语义,并提到 checkpoint policy 会调用 flush()

第 6 节展开的是:checkpoint policy 在工具层如何使用 flush()——在哪个时机调用、如果失败怎么办、崩溃后留下什么。

第 5 篇的语义第 6 节的应用
append = 内存工具执行前需要 flush 才能保证可恢复
flush = 磁盘持久化checkpoint 在 body 前调用 flush
恢复依赖持久化的 turn/end恢复也依赖持久化的 tool/call 配对检查

6.7 本节小结

问题答案
checkpoint 是什么?body 执行前调用 flush(),把调用意图持久化到磁盘
为什么需要?防止“外部已变、日志没有记录”的不可判定状态
位置在哪?pre-execute/guard 之后、body 之前
flush 失败怎么办?fail-closed:拒绝执行,不制造不可判定状态
崩溃后结果丢了怎么办?返回 TOOL_OUTCOME_UNKNOWN,让模型确认外部状态,不自动重试

SOURCEpackages/session/session-checkpoint-policy/src/index.tstools/execute listener;packages/session/session-checkpoint-policy/README.md 的 fail-closed、嵌套调用与 TOOL_OUTCOME_UNKNOWN 恢复说明。


7. 执行后:结果治理、取消与顺序

前面几节回答的是“工具能否开始执行”和“执行时如何受限”。这一节回答:工具 body 返回后,结果还需要经过什么处理,才能成为 Session 里的一条记录?

这些内容在第 4 篇已经分散出现过,本节把它们收束成一张速查表,并补上两个容易被忽略的边界。

7.1 执行后的三件事

工具 body 返回后,结果并非直接写回 Session。它还要依次经过三个阶段:

阶段做什么谁负责失败影响
tools/post-execute + finalizeContent检查结果、改写 content、阻断反馈、追加 additionalContexts策略插件 + 工具定义失败时结果不提交
tools/result发实时通知给 UI / 指标 / 日志observer(旁观者)observer 失败不影响结果
commitReady()按模型调用顺序写入 Session,并将 additionalContexts 入 InboxAgent Loop写入失败则 Turn 失败

这三个阶段是串行的,但职责完全不同:

阶段能不能改结果失败后是否阻断提交流程
post-execute + finalizeContent能改 content,能阻断会阻断
tools/result只读旁观不阻断
commitReady()只做顺序提交会阻断

为什么 observer 不能阻断?

tools/result 是给 UI、指标、日志插件“看”的通知。如果一个 observer 插件抛错了(比如 UI 渲染失败),不应该导致工具结果无法写入 Session。结果已经产生了,UI 看没看到是 UI 自己的问题。

为什么 post-execute 能阻断,但只改 content 不等于安全处理?

post-execute 是策略可以检查结果的最后机会。比如某个工具返回了敏感数据,策略可以阻断它,不让它进入模型上下文。

但一个容易犯的边界错误是:只改 content,不改 canonical

{
  "canonical": { "temperature": 25, "city": "Beijing", "apiKey": "secret" },
  "content": "北京今天晴,25°C",
  "meta": { "icon": "☀️" }
}

如果策略只是把 content 里的“25°C”删掉,但 canonical.apiKey 仍然存在,那么某个同进程的消费者仍然可以读到 apiKeycontent 是模型视图,canonical 才是结构化真源。要真正阻断数据访问,必须根据消费者边界决定替换或阻断哪个字段。

7.2 取消时的顺序:等待 body 停稳

第 4 篇已经详细定义了 Turn 取消的语义,这里只补充一点:

取消信号到达后,如果 body 已经开始执行,Tool Runtime 会等待 body 自然结束或超时,再返回取消结果,而不是立刻放弃。

做法含义风险
Promise.race() 立即放弃取消信号来了,不等 body,直接返回body 可能还在后台运行,你不知道它是否完成、是否还在执行
等待 body 停稳取消信号来了,等待 body 结束或超时,再返回body 可能浪费一些时间,但状态是确定的

为什么不用 Promise.race()

因为外部副作用是真实的。如果 kubectl rollout restart 已经开始执行,放弃等待不代表命令停止了——它可能还在继续。上层已经认为任务取消了,但命令还在后台运行,系统状态变得不可判定。

7.3 本节小结

问题答案
执行后的三个阶段是什么?post-execute 可改写结果,tools/result 只旁观,commitReady() 保证顺序提交
observer 失败会阻断结果吗?不会,observer 只旁观,不影响结果提交
只改 content 能隐藏数据吗?不能,canonical 仍然存在,其他消费者可能读到它
取消时如何对待正在运行的 body?等待 body 停稳,不立即放弃,保证状态确定

执行后三阶段的完整数据流

为了帮助你理解这一节在整个工具调用链路中的位置,这里把执行前后的完整数据流串起来:

模型产生 tool-call

② 参数校验

③ pre-execute 策略

④ 审批

⑤ guard

⑥ checkpoint 耐久化

⑦ sandbox 约束

⑧ 工具 body 执行

⑨ post-execute + finalizeContent

tools/result(实时通知)

⑩ commitReady 顺序写入 Session 并入 Inbox

从第 2 节到第 7 节覆盖了从“模型输出 tool-call”到“结果成为 Session 持久事实”的完整路径:

环节对应节核心问题
参数校验第 3 节参数合法吗?
pre-execute / guard第 4 节这次调用应不应该开始?
approval第 4 节需要外部确认吗?
checkpoint第 6 节副作用前的意图持久化了吗?
sandbox第 5 节执行时能碰什么?
body 执行第 4 节实际执行工具
post-execute / tools/result / commitReady第 7 节结果如何交给模型?

SOURCEpackages/core/tools/README.md 的 post-execute、finalizeContent、cancellation 与 result observer 约定;packages/core/agent-loop/src/tool-calls.ts 的并行 pool 与 commitReady()


8. 用“重启服务”走完一遍:每个失败点到底阻断了什么?

前面几节把工具调用的治理链路拆成了 9 个阶段。这一节用一个具体例子把整条链串起来——回到开头的 kubectl rollout restart deploy/orders

下面的表格记录的是这条路径上每个阶段如果出错,会发生什么。它不是“理想路径”,而是工程上更有价值的故障地图。

8.1 全链路故障表

时刻发生的事实如果这里出问题外部世界变了没?日志里留下什么?
1模型产生 bash tool-call参数格式不对,schema 校验失败没变一条规范错误结果,不会进入审批,不会执行任何东西
2Loop 把 tool/call 追加到 Sessionappend() 本身失败没变当前 Step 无法继续,没有可信的调用记录
3策略判定需要审批(ask无人应答、用户拒绝或取消没变拒绝结果;approval/asked + approval/decided 成对存在时可追溯
4guard 执行安全底线拒绝(如凭证黑名单)没变拒绝结果,且后续 listener 不能再把它改成“允许”
5checkpoint 执行 flush()flush 失败,或在等待落盘时被取消没变ABORTED_BEFORE_DISPATCH,不会进入 body
6sandbox 启动命令沙箱不可用、文件访问被拦没变(命令未成功启动)错误结果,绝不无约束降级
7工具 body 开始执行(命令已发起)主进程硬崩溃可能已变tool/call 已落盘,但没有 tool/result;恢复后补 TOOL_OUTCOME_UNKNOWN
8body 返回,Loop 提交结果后续结果处理失败或被取消可能已变结果按具体错误/取消语义写入,不能假装成功
9additionalContexts 入 Inbox下一 Step 前崩溃结果已经产生,以日志为准依第 5 篇的设计:tool/result + agent/inbox/spliced 两条记录共同决定恢复行为

8.2 这张表想说明什么?

重点不是“DSH 绝不会出错”,而是每一种错误都被安排在恰当的边界上

阶段原则为什么
执行前(时刻 1-6)错误发生时,外部世界没有被改变调用还没真正执行,阻断是安全的
执行中(时刻 7)崩溃不假装成功,但意图已记录外部可能已变,日志里至少留下“我准备做什么”
执行后(时刻 8-9)结果按实际状态写入,不猜测body 可能已完成,按真实结果记录;入队失败可恢复

8.3 如果你正在设计自己的 Agent 项目

这张表比照搬一个“危险工具确认弹窗”更值得复用。在设计阶段,先给每个工具回答清楚这组问题:

  1. 副作用类型:这个工具是只读的,还是会修改外部状态?
  2. 幂等性:重复执行会有什么后果?
  3. 审批人:谁有权批准这个操作?
  4. sandbox mode:执行时应该限制在什么范围?
  5. checkpoint 边界:执行前必须持久化什么?
  6. 未知结果后的处置:崩溃后恢复时,应该重试、查询外部状态,还是标记失败?

然后再考虑界面怎么画。


9. 治理链路能迁移到哪些场景?

这套治理链路本质上回答了六个通用问题:

模型能看到什么?→ 参数合法吗?→ 允许执行吗?→ 有底线红线吗?→ 需要外部确认吗?→ 执行时受什么约束?→ 结果怎么交给模型?

任何工具调用都绕不开这组问题。下面用六个不同领域的具体场景展示这套框架的通用性。

9.1 六场景对照表

场景工具调用示例pre-execute / approval执行约束未知结果处置
运维助手发布、扩缩容、重启生产环境 ask;高危 deny受限凭证、目标集群白名单查询发布状态,不直接重试
客服助手发券、退款、改订单金额/频率阈值 ask;超限 denyAPI scope、租户隔离、用户身份校验用业务单号查询结果
知识助手检索内部文档、数据库数据分类策略;敏感库 ask数据源 ACL、脱敏代理标记检索结果“未确认”
自动化工作流发邮件、创建工单收件人域名与模板策略邮件/工单 API scoped token用幂等键或外部 ID 查重
财务/数据分析导出报表、执行统计查询数据范围 ask;大查询 deny只读副本、脱敏视图、行级权限检查数据快照版本,不直接重跑
代码审查 / 研发效能提交 PR、合入主干、回滚受保护分支 ask;不合规 deny分支保护规则、CI 检查门禁查询 PR 状态,不重复提交
供应链 / 采购创建采购单、修改供应商信息金额阈值 ask;不合规 denyERP 权限模型、审批流绑定用采购单号查询执行状态
人力资源 / 员工服务更新员工信息、发起入职流程敏感字段 ask;越权 deny组织架构 scope、数据脱敏用员工 ID 查询 HR 系统状态
物联网 / 边缘设备重启设备、更新固件、修改配置设备类型 ask;高危 deny设备分组权限、操作日志查询设备状态,不盲目重试

9.2 场景背后的共同结构

所有场景都遵循同一套模式:

治理层每个场景都要问的同一个问题
可见性控制这个 Agent 应该看到哪些工具?
参数校验模型传的参数合法吗?
pre-execute这次调用应该允许、拒绝还是询问?
guard有没有不可退让的底线规则?
approval需要谁确认才能继续?
checkpoint执行前需要留下什么可恢复记录?
执行约束执行时能访问什么范围?
结果治理结果如何分发给模型、UI、审计?

9.3 迁移时不要做的事

不要把“治理”简化成一个权限开关。

常见错误是把“模型能不能执行这个工具”浓缩成一个布尔值(allow: true/false)。这是最早期的 Agent 设计方式——它无法回答“为什么这次拒绝了那次却允许了”“谁批准了这次高危操作”“崩溃后如何恢复”。

分层治理的收益在于

问题如果只有一个权限开关如果分层设计
“为什么这次拒绝了?”不知道,只有 allow/deny可以追溯到具体层(策略、guard、审批人、参数校验)
“谁批准了这次操作?”没有记录approval/asked + approval/decided 成对审计
“崩溃后怎么判断?”不知道tool/call + tool/result 配对检查
“这个 Agent 能看到什么工具?”全有或全无restrict() 控制可见性

9.4 最小可行迁移

如果要在自己的项目中落地这套分层治理,不一定要实现全部九层。四层即可:

四层简化对应的 DSH 机制每个项目都可以有
① 可见性restrict()Agent 按角色看到不同工具列表
② 允许/拒绝/询问pre-execute + ask策略判断 + 人工审批
③ 执行约束sandbox / ACL / scope token执行时限制能碰什么
④ 可恢复留痕tool/call + tool/result每次调用都有开始和结束记录

最重要的不是复刻 DSH 的全部实现,而是理解“模型意图”和“系统执行”之间的那条边界必须被显式治理——而不只是一个 if (allowed) { execute(); }


10. 源码阅读地图与实验

本篇建议不要从 bash 工具开始倒读。先读通用 Tool Runtime,再看它如何被审批、checkpoint、sandbox consumer 连接起来:

packages/core/tools/README.md
  -> packages/core/tools/src/index.ts
  -> packages/core/tools/tests/tools.spec.ts

packages/interaction/user-approval/README.zh.md
  -> packages/interaction/user-approval/tests/approval.spec.ts

packages/session/session-checkpoint-policy/src/index.ts
  -> packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts

packages/sandbox/sandbox/src/index.ts
  -> packages/sandbox/sandbox/src/escalation.ts
  -> packages/sandbox/sandbox/tests/escalation.spec.ts

11. 本篇结论

现在可以把“模型调用工具”改写成一句更精确的话:

模型只提出一个待验证的行动意图;DSH 先决定它是否可见、是否允许、是否获得一次性审批和足够的实际资源约束,再在副作用前留下耐久事实,最后才执行,并按模型顺序把结果交回下一 Step。

这句话拆分出五条可复述的原则:

原则核心意思一句话记忆
① 可见性 ≠ 授权restrict() 只控制模型能看到什么,不能替代执行期策略“看不见”不等于“不能执行”
② 策略可组合,底线不可逆pre-execute 处理可替换的部署策略;guard 是单调的,拒绝后不能被重新放行策略可以商量,底线不能商量
③ ask 是一次性、可审计、缺失即拒绝审批请求不是长期 token;通道不可用时必须 fail-closed没人应答,就等于拒绝
④ sandbox 是执行期约束,prompt 不是 enforcementsandbox 决定已获准调用实际能碰什么;prompt 只是说明告诉模型“有限制”,不等于真的有限制
⑤ checkpoint 不保证外部操作完成,但保证意图可恢复副作用前先留痕;中断后的未知结果必须显式表示为 TOOL_OUTCOME_UNKNOWN不假装知道不知道的事

系列位置

到这里,第 2 至第 6 篇已经形成一条连续链:

Profile/Bundle(第2篇)→ 装配能力
Cordis(第3篇)→ 管理能力的生命周期
Agent Loop(第4篇)→ 调度一次任务
Session(第5篇)→ 保存可信历史
Tool Runtime(第6篇)→ 把模型意图变成受控行动

12. 闭卷复述

不看文章,尝试回答下面六题:

  1. 为什么 ctx.tools.restrict() 不能作为安全授权边界?
  2. tools/result 与 Session 的 tool/result 分别由谁产生、分别服务谁?
  3. ask 没有可用 approval answerer 时,为什么应拒绝而非默认允许?
  4. pre-execute 与单调 guard 的职责有什么不同?
  5. sandbox prompt、执行期 policy、sandbox backend 各自解决什么问题?
  6. 为什么顶层工具 body 前的 checkpoint 能改善崩溃恢复,却不能保证崩溃时外部命令到底有没有完成?

能不看源码回答清楚,再回到以下四个入口逐段验证:

packages/core/tools/src/index.ts
packages/core/agent-loop/src/tool-calls.ts
packages/session/session-checkpoint-policy/src/index.ts
packages/sandbox/sandbox/src/escalation.ts

下一篇将进入另一个经常被混为“上下文窗口”的问题:Context、Skill、Compaction 与 KV Cache 如何共同决定模型到底看见什么、何时压缩,以及为什么“压缩历史”不能破坏可恢复与可审计的 Session 真源。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐