도구 출력 계약: 값과 표시 분리
같은 결과라도 모델이 보는 것과 사람이 보는 것은 다를 수 있습니다. 핵심 소스: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-safe 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 PostToolDecision 타입 정의 593–600행, 확인일 2026-08-13。
왜 동시에 못 바꾸나요? 의미가 다르기 때문입니다. content 교체는 표시 계층 — 값은 그대로, 모델이 보는 텍스트만 바꿉니다. value 교체는 데이터 계층 — 레지스트리가 새 값으로 schema를 다시 돌리고 content·meta를 다시 계산해 세 프로젝션이 같은 출처를 갖게 합니다. 둘 다 허용하면 텍스트는 A, 값은 B인 분열이 납니다. 문서는 핵심을 덧붙입니다: 콘텐츠 교체는 표시 전략이고, 프로그램에게 값을 숨기려면 값을 바꾸거나 block해야 합니다. 텍스트만 고쳐서는 Code Mode에서 값을 읽는 프로그램을 속일 수 없습니다(docs/subsystems/tools.zh.md 「后置策略」 절).
두 가지 폴백 질문도 답이 있습니다. render가 예외를 던지면 레지스트리가 JSON-safe isError로 바꾸고 모델은 오류 텍스트를 봅니다. 서드파티 도구가 presentCall / presentResult를 안 쓰면 클라이언트는 generic 카드로 폴백합니다: 제목=도구 이름, 원본 인자를 입력으로 표시(index.ts 79–83행 주석이 이 폴백을 명시). 둘 다 안 죽고, 둘 다 착지가 있습니다.
Claude Code의 렌더링은 도구 인터페이스에 바로 붙어 있습니다. Tool 인터페이스에 UI용 renderToolResultMessage(), 포맷 변환용 mapToolResultToToolResultBlockParam()가 있고(study/chapters/02-tool-system.md 96–98행), 도구 파일은 .tsx이며 렌더는 도구 자체 React 컴포넌트입니다. 장점은 작성자가 픽셀을 통제하는 것, 대가는 클라이언트(터미널→에디터 플러그인)를 바꾸면 렌더 레이어를 다시 써야 하고 리플레이도 렌더 코드를 다시 실행해야 한다는 점입니다. DSH는 이 층을 데이터로 바꿉니다: 도구는 render intent만 선언하고, 여섯 카드 어휘는 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 프로그램이 받는 게 무엇인지 생각해 보세요.