工具输出契约:值与展示分离
同一个结果,模型看的和人看的可以不一样。核心源码:packages/core/tools/src/index.ts 与 presentation.ts。
等待 execute() 返回…
schema 校验
packages/core/tools/src/index.ts 第 211 至 219 行的输出契约与 presentation.ts 的 card 联合类型。玩的时候对比左右两栏:同一个 value,两份完全不同的呈现。先回答标题里的问题:工具结果到底是字符串还是结构化值?在 DSH 里两个都是,但地位不同。工具的 execute 只返回一个规范 JSON 值(canonical value),这个值必须通过工具自己声明的 output.schema 校验。字符串是后来才有的:注册表拿着校验过的值调用 render(args, value),投影出模型看到的内容块。
所以链路是:execute 产出值,schema 把关,render 投影模型内容,可选的 presentationMeta 投影一份可回放的 UI 数据,presentResult 再把它变成一张卡片。render 和 presentResult 都是纯函数,不做 I/O,因为它们在实时流式输出和会话日志回放两条路径上都要跑,跑出来必须一样。
UI 那边拿到的东西叫渲染意图(render intent):一个带 card 标签的联合类型,值域是 generic、terminal、diff、read、search、web 六种卡片。客户端只需要对 card 做 switch,不需要认识任何工具名。换一个搜索后端 provider,工具实现整个换掉,只要它还产出 search 卡,UI 一行不用改。这就是 UI 契约与工具实现解耦的意思。
value 只活在执行期持久化的 tool/result 事件只存 content、error 和 meta,规范值从不落盘。回放可以重现每一张卡片和每一段模型文本,却重建不了中间值(docs/subsystems/tools.zh.md「结果仅承载产出」一节)。
投影坏了不等于崩了值没过 schema、render 抛异常、presentationMeta 产出非 JSON,全部转成 JSON 安全的 isError 结果。模型看到一条错误文本,流水线照常走完,出处在 index.ts 第 1793 行起的 createSuccessResult。
截断必须亮牌search 卡强制携带 truncated 和 total 两个字段,UI 永远不会把砍过的结果当完整结果画出来(presentation.ts 第 223 至 231 行)。read 卡同理带 offset 和 totalLines,能画出「显示 N 行,共 M 行」。
输出契约的全部字段就九行。schema 是强制的,render 是强制的,presentationMeta 可选。注意两个投影器的注释都强调 Pure:这是回放确定性的地基。
/** Tool-owned canonical output contract used after the body returns a JSON value. */
export interface ToolOutputDefinition {
/** Raw supported JSON Schema enforced against every successful canonical value. */
readonly schema: JsonSchemaNode
/** Pure projection from validated arguments and value to Native/model content. */
render(args: unknown, value: JsonValue): ContentBlock[]
/** Pure replayable presentation projection, computed only for top-level calls. */
presentationMeta?(args: unknown, value: JsonValue): JsonValue
}
packages/core/tools/src/index.ts,核对日期 2026-08-13。代码块保留源码原文。值与展示分离还解释了 post-execute 插件的一条怪规矩:accept 的时候,换 content 和换 value 只能二选一。这条规矩不是靠文档约定,是写死在类型定义里的。PostToolDecision 的 accept 有两个分支:一个分支允许带 content,同时把 value 字段的类型标成 never;另一个分支反过来,允许带 value,把 content 标成 never。TypeScript 里 never 类型没有任何合法取值,谁想在一个决定里同时塞两个字段,编译器直接报错。第三个分支是 block,把纠正性反馈变成错误结果。
出处:packages/core/tools/src/index.ts 第 593 至 600 行的 PostToolDecision 类型定义,核对日期 2026-08-13。
为什么不许同时换?因为两边语义不一样。换 content 是展示层的动作:值保持原样,只改模型看到的文本。换 value 是数据层的动作:注册表会拿新值重新过一遍 schema,再重新算 content 和 meta,保证三份投影出自同一个源头。允许同时换,就可能出现文本说 A、值是 B 的分裂结果。文档还补了一句要害提醒:内容替换是展示策略,想对程序隐藏值的插件必须换值或者 block,光改文本瞒不住 Code Mode 里拿值的程序(docs/subsystems/tools.zh.md「后置策略」一节)。
两个兜底问题也有了答案。render 抛异常,注册表把它转成 JSON 安全的 isError,模型看到错误文本。第三方工具没写 presentCall / presentResult,客户端回退到 generic 卡:标题就是工具名,原始参数当输入展示(index.ts 第 79 至 83 行的注释写明了这条回退)。都不崩,都有着落。
Claude Code 的渲染直接长在工具接口上。Tool 接口里有 renderToolResultMessage() 负责 UI 渲染、mapToolResultToToolResultBlockParam() 负责格式转换(书稿 study/chapters/02-tool-system.md 第 96 至 98 行的接口分类图),工具文件本身是 .tsx,渲染逻辑是工具自带的 React 组件。这条路线的好处是工具作者掌控每个像素,代价是换一个客户端(比如从终端换到编辑器插件)就要重写渲染层,回放也需要重新执行渲染代码。DSH 把这层翻译成了数据:工具只声明渲染意图,六种卡片词汇是 host 和 client 之间的中立协议,谁来渲染都行。
结果超限的处理也能对上:CC 用 maxResultSizeChars,超了就落盘、给模型留预览加路径(study/chapters/02-tool-system.md 第 463 至 496 行);DSH 的对应机制是 spill 策略,在 Compaction 双路径 一课讲过。两家都想清楚了同一件事:工具结果的体量必须有人管,不能放任它撑爆上下文。
Grok Build 用 Rust 枚举给工具输出做类型化:比如 search_replace 的输出是 SearchReplaceOutput 枚举,InvalidInput、NoMatchesFound 这些失败形态在编译期就定死了(crates/codegen/xai-grok-tools/src/implementations/grok_build/search_replace/mod.rs)。输入侧同样讲究,面向模型的 canonical input 做成稳定投影,站内 Canonical input 是稳定投影 有完整拆解。至于输出的 UI 呈现与模型文本是否像 DSH 这样走统一的卡片词汇,已核对的 Grok 材料里未见等价机制,这条结论基于已公开证据保留。
给一个 SQL 查询工具设计输出契约
你要接入一个第三方 sql_query 工具,查询返回 1200 行但只保留前 50 行。请写出:value 的 schema 大致长什么样(提示:rows、total、truncated 三个字段少不了);render 给模型的文本要不要包含全部 50 行;presentResult 选六种卡片里的哪一种,截断信息放哪。最后一问:安全插件想对模型隐藏其中的手机号列,在 post-execute 里该换 content 还是换 value?想想 Code Mode 里程序拿到的是什么。