DeepSeek Harness · Tool System

Tool Output Contract: Value Separated from Display

Same result — what the model sees and what people see can differ. Core source:packages/core/tools/src/index.ts and presentation.ts

Course goalAfter this lesson you can explain three things: in DSH a tool’s return is a schema-validated structured JSON value, and both the model-facing text and the UI card are projections of that value; the UI renders via a card-tagged union type and never needs to know tool names; persistence stores projections, not the value — so replay can recreate every card, but can never rebuild the intermediate value.
Interactive demo · Dual-view gallery
canonical value Waiting for execute()… schema check
Model viewrender(args, value)
The text that enters context and is billed by token
UI viewpresentResult(args, result)
Render intent for the client: a card tagged with card
Session log to disk: content meta value
Pick a scenario and hit Play, or scroll here to auto-play the read scenario.
Teaching simulation: values, text, and cards are course-adapted examples. The projection relation matches the output contract in packages/core/tools/src/index.ts lines 211–219 and the card union in presentation.ts. Compare the two panes: same value, two totally different presentations.
Mechanism · One value, three projections

First, the title question: is a tool result a string or a structured value? In DSH it’s both — with different status. execute returns only one canonical JSON value, which must pass the tool’s own output.schema. Strings come later: the registry takes the validated value, calls render(args, value), and projects the content blocks the model sees.

So the chain is: execute produces the value, schema gates it, render projects model content, optional presentationMeta projects replayable UI data, and presentResult turns that into a card. Both render and presentResult are pure — no I/O — because they run on live streaming and session-log replay, and must produce the same output.

What the UI gets is render intent: a card-tagged union whose domain is six cards — generic, terminal, diff, read, search, web. The client only switches on card; it never needs tool names. Swap the search backend provider and the whole tool implementation — as long as it still emits a search card, the UI doesn’t change a line. That’s UI contract decoupled from tool implementation.

value lives only at execution time

Persisted tool/result events store only content, error, and meta — the canonical value never hits disk. Replay can recreate every card and every model text, but not the intermediate value (docs/subsystems/tools.zh.md, “结果仅承载产出” section).

A broken projection ≠ a crash

Schema failure, render throwing, or presentationMeta emitting non-JSON — all become a JSON-safe isError result. The model sees an error string; the pipeline still finishes. Source: createSuccessResult from line 1793 in index.ts.

Truncation must show its hand

Search cards must carry truncated and total, so the UI never paints a cut result as complete (presentation.ts lines 223–231). Read cards likewise carry offset and totalLines, so they can show “lines N, of M total”.

Core visual · Projection relationship
execute() returns canonical value (JSON) output.schema enforced every time render(args, value) content blocks model-facing, into context presentationMeta(args, value) meta presentation data replayable, persisted with the log presentResult(args, result) card render intent UI-facing, switch(card) session log content + meta value never persisted discarded when execution ends
Teaching diagram: three projections map to ToolOutputDefinition in index.ts lines 211–219 and the two present callbacks at lines 84–92.
Key evidence · Contract & either-or

The whole output contract is nine lines. schema is required, render is required, presentationMeta is optional. Both projector comments stress Pure — that’s the foundation of replay determinism.

packages/core/tools/src/index.tslines 211–219
/** 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
}
Source snapshot note:Based on the local deepseek-harness-master repo; verified against packages/core/tools/src/index.ts, verified on 2026-08-13. Code blocks keep the original source text.

Value/display separation also explains a quirky post-execute plugin rule: on accept, you may replace content or value — never both. It’s not a doc convention; it’s baked into the types. PostToolDecision accept has two branches: one allows content and types value as never; the other allows value and types content as never. In TypeScript, never has no legal inhabitants — stuff both fields into one decision and the compiler errors. The third branch is block, turning corrective feedback into an error result.

Source:packages/core/tools/src/index.ts PostToolDecision type definition, lines 593–600, verified on 2026-08-13。

Why not both at once? Different semantics. Replacing content is a display-layer move: keep the value, change only model-facing text. Replacing value is a data-layer move: the registry re-runs schema on the new value, then recomputes content and meta so all three projections share one source. Allow both and you can get text saying A while the value is B. Docs add a key warning: content swap is a presentation tactic — plugins that must hide a value from programs must swap the value or block; rewriting text alone won’t fool Code Mode code that reads the value (docs/subsystems/tools.zh.md, “后置策略” section).

Two fallback questions are answered too. If render throws, the registry turns it into a JSON-safe isError and the model sees error text. If a third-party tool skips presentCall / presentResult, the client falls back to a generic card: title = tool name, raw args as input (index.ts lines 79–83 comments spell out this fallback). Nothing crashes; everything has a landing place.

Side-by-side · Where rendering lives

Claude Code keeps rendering on the tool interface itself. The Tool interface has renderToolResultMessage() for UI and mapToolResultToToolResultBlockParam() for format conversion (study/chapters/02-tool-system.md lines 96–98 interface map). Tool files are .tsx; rendering is the tool’s own React component. Upside: authors control every pixel. Cost: swap clients (terminal → editor plugin) and you rewrite the render layer; replay must re-run render code. DSH turns that layer into data: tools only declare render intent; six card vocabularies are a neutral host↔client protocol — anyone can render.

Oversized-result handling lines up too: CC uses maxResultSizeChars — spill to disk, leave the model a preview plus path (study/chapters/02-tool-system.md lines 463–496). DSH’s counterpart is the spill policy, covered in Compaction Dual Paths. Both sides nailed the same point: someone must police tool-result bulk so it can’t blow up context.

Grok Build types tool output with Rust enums: e.g. search_replace yields SearchReplaceOutput, with InvalidInput, NoMatchesFound and other failure shapes fixed at compile time (crates/codegen/xai-grok-tools/src/implementations/grok_build/search_replace/mod.rs). Inputs are equally careful — model-facing canonical input is a stable projection; see Canonical input is a stable projection. Whether output UI and model text share a unified card vocabulary like DSH — no equivalent showed up in reviewed Grok materials; that conclusion stays provisional on public evidence.

Classroom Exercise
01

Design an output contract for a SQL query tool

You’re integrating a third-party sql_query tool: the query returns 1200 rows but you keep only the first 50. Write: roughly what the value schema looks like (hint: rows, total, truncated are non-negotiable); whether render’s model text should include all 50 rows; which of the six cards presentResult should pick, and where truncation info goes. Final question: a security plugin wants to hide a phone-number column from the model — in post-execute, swap content or value? Think about what Code Mode programs actually receive.

Takeaway:A tool produces a schema-validated value; model text and UI cards are pure projections of it — pick one channel per change, never mix. The UI knows card tags, not tool names; swap implementations without touching the UI. Persistence stores projections, not values: replay recreates every display; the value itself vanishes when execution ends.