DeepSeek Harness · 도구 시스템

도구 출력 계약: 값과 표시 분리

같은 결과라도 모델이 보는 것과 사람이 보는 것은 다를 수 있습니다. 핵심 소스:packages/core/tools/src/index.tspresentation.ts

강의 목표이 레슨을 마치면 세 가지를 말할 수 있어요: DSH에서 도구 반환값은 schema로 검증된 구조화 JSON 값이고, 모델이 보는 텍스트와 UI 카드는 모두 그 값의 프로젝션입니다; UI는 card 태그 유니온 타입으로만 렌더하며 도구 이름을 알 필요가 없습니다; 영속화는 프로젝션만 저장하고 값은 저장하지 않아서, 리플레이는 모든 카드를 재현하지만 중간 값은 영원히 재구성할 수 없습니다.
인터랙티브 데모 · 이중 시점 갤러리
정규 값 value execute() 반환 대기… schema 검증
모델 시점render(args, value)
컨텍스트에 들어가고 토큰으로 과금되는 텍스트
UI 시점presentResult(args, result)
클라이언트가 받는 render intent: card 태그가 달린 카드
세션 로그 디스크 저장: content meta value
시나리오를 고른 뒤 「재생」을 누르거나, 여기로 스크롤하면 read 시나리오가 자동 재생됩니다.
수업용 시뮬레이션입니다. 값·텍스트·카드는 수업에 맞게 정리한 예시이며, 프로젝션 관계는 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 카드는 truncatedtotal을 반드시 갖고, UI는 잘린 결과를 완전한 결과처럼 그리지 않습니다(presentation.ts 223–231행). read 카드도 offset·totalLines로 “N행 표시, 총 M행”을 그릴 수 있습니다.

핵심 시각 · 프로젝션 관계도
execute() 반환 정규 값 value (JSON) output.schema 매회 강제 검증 render(args, value) content 콘텐츠 블록 모델용, 컨텍스트 진입 presentationMeta(args, value) meta 표시 데이터 리플레이 가능, 로그와 함께 영속화 presentResult(args, result) card render intent UI용, switch(card) 세션 로그 content + meta value는 디스크에 안 남음 실행이 끝나면 폐기
수업용 구조도: 세 프로젝션은 index.ts 211–219행 ToolOutputDefinition과 84–92행의 present 콜백 두 개에 대응합니다.
핵심 증거 · 계약과 양자택일

출력 계약 필드는 아홉 줄뿐입니다. schema는 필수, render는 필수, presentationMeta는 선택입니다. 두 프로젝터 주석이 모두 Pure를 강조합니다 — 리플레이 결정성의 토대입니다.

packages/core/tools/src/index.ts211–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
}
소스 스냅샷 안내:로컬 저장소 deepseek-harness-master 기준, 확인 파일 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 자료에는 등가 메커니즘이 없어, 공개 증거 기준으로 이 결론을 보류합니다.

수업 실습
01

SQL 쿼리 도구의 출력 계약 설계하기

서드파티 sql_query 도구를 붙입니다. 쿼리는 1200행을 반환하지만 앞 50행만 남깁니다. 적어 보세요: value schema는 대략 어떤 모양인지(힌트: rows·total·truncated는 필수); render가 모델에 주는 텍스트에 50행 전부 넣을지; presentResult는 여섯 카드 중 무엇을 고르고 잘림 정보는 어디에 둘지. 마지막: 보안 플러그인이 전화번호 열을 모델에게 숨기려면 post-execute에서 content를 바꿀까요, value를 바꿀까요? Code Mode 프로그램이 받는 게 무엇인지 생각해 보세요.

Takeaway:도구는 schema로 검증된 값을 내고, 모델 텍스트와 UI 카드는 그 값의 순수 함수 프로젝션입니다 — 어느 쪽을 고칠지는 채널을 택하고 섞지 마세요. UI는 card 태그만 알고 도구 이름은 모릅니다. 구현을 바꿔도 UI는 그대로입니다. 영속화는 프로젝션만 저장하고 값은 저장하지 않습니다: 리플레이는 모든 표시를 재현하고, 값 자체는 실행이 끝나면 사라집니다.