审批被拒也要形成终态
一句话判断
Vercel AI SDK 在 7 月 17 日发布的 ai@7.0.31 修了一个很值得 Agent Engineer 关注的小问题:用户在客户端拒绝一个工具调用,不应该只留下“有人点了拒绝”的审批痕迹,而必须把这次调用推进到一个稳定、可回放、可持久化的终态。 这次修复把被拒工具从 approval-responded 推进到 output-denied,同时避免向模型重复注入 execution-denied 结果。我觉得它揭示的是一个很核心的 runtime 规律:审批结果不是 UI 事件,而是工具状态机的一部分。
背景:为什么“拒绝”也要当成输出处理
很多系统把 tool approval 想成一个插在执行前的人类按钮:同意就执行,不同意就结束。但一旦系统有流式 UI、持久化消息、resume/replay、审计日志,这个理解就不够了。
Vercel 这次修的根因很典型:客户端已经生成了一个 synthetic 的 execution-denied tool result,但运行时在 collectToolApprovals 阶段看到“已经有 tool result 了”,就把这个 denied approval 丢掉,于是 streamText 不会发出 tool-output-denied。结果是模型侧其实知道这次调用被拒了,UI 和持久化状态却还停在 approval-responded,像是“回复了审批,但工具还没真正结束”。
对 Agent 平台来说,这种错位很危险:模型 transcript、前端状态、审计视图会各说各话,恢复执行时也很难判断这次调用到底是 pending、cancelled,还是已经以 denial 终止。
这次修复真正重要的地方
第一,它承认拒绝也是终态迁移。不是“审批流程结束了”就算完,而是工具调用本身要进入 output-denied,这样 persisted state、rendered state 和 live stream 才一致。
第二,它承认同一个事实可能要投影到两个通道:
- 对模型来说,需要一个
execution-denied结果,让后续推理知道这次工具没执行。 - 对 UI / runtime event stream 来说,需要一个
tool-output-denied事件,让界面、恢复逻辑、审计系统知道这次调用已经落到终态。
这两个通道不能混成一个,也不能因为一个已经存在就把另一个吞掉。Vercel 现在做的是:如果历史里已经有 execution-denied tool result,就继续发 denied 状态事件,但不再重复生成第二份模型侧 tool result。也就是“状态要补齐,结果要去重”。
第三,它把 call_id 视角保住了。一次工具调用真正稳定的锚点不是某个按钮组件,也不是某条提示文本,而是围绕同一个 tool call identity 发生的 request / response / result / output-state 投影。没有这个身份锚点,approval、resume、replay、trace 基本都会散。
对 OPC / Agent runtime 设计的直接启发
如果以后 Dewey / OPC 里有高风险工具——发群消息、改配置、执行 destructive command、合并代码——我会把批准/拒绝路径设计成明确的状态机,而不是若干 if-else:
pending-approval只是中间态,不是结果。approved和denied是决策事实,但还要继续投影成工具终态。- 工具终态至少要区分
output-available、output-denied、output-error。 - 模型 transcript、UI event、持久化记录、审计 trace 都必须围绕同一个
toolCallId对齐。 - 任一恢复路径都要能回答:这次调用是否真正结束、以什么原因结束、模型是否已经见过这条结果。
否则系统表面上只是一个“小 UI bug”,本质上却是在把 denial 这种安全关键事件降格成前端交互细节。
一个常见误区
很容易觉得:既然模型已经收到了“用户拒绝了”这条信息,就没问题了。其实不够。模型知道,不代表产品知道;产品知道,不代表审计知道;审计知道,不代表恢复逻辑知道。真正可托管的 Agent runtime,要让“拒绝”在所有投影视图里都成为同一个可验证的终态。
留给 Dewey 的问题
如果 OPC 现在加一个“高风险工具必须审批”的能力,你更想先保证哪一层绝不漂移:模型 transcript、UI 状态机、还是 replay / 审计账本?如果只能先做一层,后面另外两层大概率会在哪里失真?