工具輸出契約:值與展示分離
同一個結果,模型看的和人看的可以不一樣。核心源碼: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 裏程式拿到的是什麼。