DeepSeek Harness · 도구 시스템

파일 편집 공학: 읽은 뒤에 쓰기

read / edit / write 삼종 세트, 읽지 않은 파일은 고칠 수 없습니다. 핵심 소스:packages/fs/fs-observation-policy/src/index.ts

강의 목표이 레슨을 마치면 세 가지를 말할 수 있어요: DSH가 관측 원장으로 “이 세션이 어떤 파일을, 어느 버전으로 읽었는지”를 어떻게 기억하는지; edit가 안 읽었을 때 FS_NOT_OBSERVED로, 외부 변경 후 FS_STALE_VERSION으로 막히는 이유; 그리고 이 방어선이 탈착형 플러그인인 이유 — Claude Code와 Grok이 같은 문제에 프롬프트 규칙과 힌트 문구라는 농도 다른 답을 내놓는지.
인터랙티브 데모 · 읽은 뒤에 쓰기 챌린지
디스크의 파일 notes.md외부 변경버전 v7
버전 번호는 백엔드가 발급한 신선도 증표로, 파일이 바뀌면 함께 바뀝니다
관측 원장
observation-policy 플러그인 기록: 이 세션이 누구를, 어느 버전으로 봤는지
미관측 = 원장에 항목 없음; present@vN = 버전 vN을 읽음; absent = 없음을 확인
edit 의도 판정
도구가 fs/edit-intent를 분배하고, 플러그인이 원장을 보고 결정합니다
도구 호출 대기…
판정 대기
시나리오를 고른 뒤 「재생」을 누르거나, 여기로 스크롤하면 시나리오 A가 자동 재생됩니다.
수업용 시뮬레이션입니다. 파일 내용과 버전은 수업에 맞게 정리했고, 판정 로직은 packages/fs/fs-observation-policy/src/index.ts의 writeIntent / editIntent와 docs/subsystems/filesystem.zh.md의 오류 분류에 대응합니다. 원장을 보세요: 판정은 파일 자체를 보지 않고, 이 세션이 직접 본 것만 봅니다.
메커니즘 분해 · 원장 하나, 판정 셋

Agent가 파일을 망치는 세 가지 사고: 잘못된 위치, 안 읽은 파일 덮어쓰기, 오래된 내용으로 편집. DSH의 대응은 삼종 세트 plus 원장입니다. 삼종 세트는 모델용 read·edit·write 도구(docs/tool-catalog.zh.md): read는 행 번호가 있는 창 읽기, edit는 리터럴 교체, write는 전체 파일 생성/덮어쓰기. 원장은 fs-observation-policy 플러그인 안의 WeakMap으로, 세션을 키로 각 파일 대상의 관측 상태를 기록합니다.

원장 상태는 셋입니다. 미관측: 파일 항목이 아예 없음. present@vN: 읽었고 버전은 vN — 파일시스템 백엔드가 발급한 불투명 신선도 증표. absent: 경로가 없음을 확인(예: read 실패). read·write·edit가 성공할 때마다 도구가 fs/observed를 내고 플러그인이 원장을 맞춥니다.

판정은 손대기 전에 일어납니다. 도구가 쓰거나 고치려 할 때 fs/write-intent 또는 fs/edit-intent를 분배합니다 — 단일 슬롯 폭포: 결정을 반환한 첫 리스너가 독점하며, 배포 약속상 이 정책 플러그인입니다. 원장을 보고 가드 조건을 내고, 실제 검사는 백엔드 원자 임계 구역에서: 버전 확인 → 매칭 → 교체, 중간에 끼어들 수 없습니다.

write는 항상 길이 있다

안 읽고 write하면 가드는 createIfAbsent: 없으면 생성, 있으면 거부(FS_NOT_OBSERVED). 읽은 뒤 write는 replaceIfVersion: 버전이 맞을 때만 교체. 새 파일은 먼저 읽을 필요 없고, 남의 파일을 덮어쓸 수는 없습니다.

edit는 한 발도 안 양보

안 읽으면 바로 FS_NOT_OBSERVED, 원장이 absent면 FS_NOT_FOUND, 읽었으면 버전 가드를 달고 갑니다. 버전 검사가 리터럴 매칭보다 앞서서, 오래된 내용으로 편집하면 FS_STALE_VERSION이지 오해하기 쉬운 매칭 실패로 떨어지지 않습니다.

오류는 구조화된 신분을 갖는다

모든 실패는 안정적인 FsError code를 달고, 도구 레지스트리는 오류 결과에 { name, code }를 남깁니다. 재시도와 UI는 code로 분기하면 되고 오류 문구를 파싱할 필요 없습니다(docs/subsystems/filesystem.zh.md 「错误分类体系」).

핵심 시각 · 판정 흐름
read 성공 emit fs/observed present@vN 관측 원장 WeakMap: 세션 → 대상 → 상태 edit 호출 도착 fs/edit-intent 단일 슬롯 폭포 분배 editIntent(target) 원장만 보고 디스크는 안 봄 미관측 FS_NOT_OBSERVED absent FS_NOT_FOUND present@vN 버전 가드와 함께 통과 replaceIfVersion(vN) 백엔드 임계 구역 버전 확인 → 매칭 → 원자 교체 불일치→STALE
수업용 구조도: 판정 분기는 fs-observation-policy의 editIntent에 대응하고, 임계 구역 의미는 docs/subsystems/filesystem.zh.md 「写入与编辑守卫」에서 옵니다.
핵심 증거 · 원장 위의 두 결정

정책 플러그인 전체가 140줄도 안 되고, 핵심은 조회 함수 두 개입니다. write 판정은 세 줄 선택: 원장이 present면 버전 달린 replaceIfVersion 가드를 반환하고, 항목이 없거나 absent면 createIfAbsent를 반환해 없을 때만 생성합니다. 함수 머리 주석이 화살표 두 개로 결정표를 끝냅니다. 이게 “write는 항상 길이 있다”의 구현: 새 파일은 먼저 읽을 필요 없고, 남의 파일 덮어쓰기는 안 됩니다.

출처:packages/fs/fs-observation-policy/src/index.ts writeIntent 61–71행, 확인일 2026-08-13。

edit 판정은 더 엄격합니다 — 안 읽으면 가드도 못 받고 바로 throw. 함수 전체를 볼 가치가 있어요. throw 두 개가 이 장 제목의 전부입니다:

packages/fs/fs-observation-policy/src/index.ts78–88행
  editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
    const owner = this.owner(actor)
    const prior = owner ? this.get(owner, target.targetKey) : undefined
    if (!owner || prior === undefined) {
      throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
    }
    if (prior.kind === 'absent') {
      throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')
    }
    return { version: prior.version }
  }
소스 스냅샷 안내:로컬 저장소 deepseek-harness-master 기준, 확인 파일 packages/fs/fs-observation-policy/src/index.ts, 확인일 2026-08-13. 코드 블록은 소스 원문을 유지합니다.

반환된 { version: prior.version }이 그 신선도 증표입니다. 백엔드 editText는 먼저 현재 버전과 대조하고 불일치면 FS_STALE_VERSION; 맞아야 리터럴 매칭을 합니다. old_string은 정확히 한 번 맞아야 하고, 여러 곳이면 FS_AMBIGUOUS_EDIT, 없으면 FS_EDIT_NOT_FOUND(replace_all을 명시하지 않은 한). 매칭·개행 처리·진부 검사·원자 교체가 한 임계 구역에서 끝납니다(docs/subsystems/filesystem.zh.md 151행).

더 기억할 디테일 셋. 첫째, 방어선은 탈착형: 플러그인을 빼면 write/edit가 무조건 베어 프로바이더 동작으로 돌아가고, 도구 schema는 한 글자도 안 바뀝니다 — 도구는 이벤트만 분배하고 정책을 직접 부르지 않으니까요. 둘째, read 인가는 신선도만 보고 전체/창 읽기를 가리지 않습니다: 파일이 안 바뀌면 10행만 읽어도 이후 전체 edit를 인가합니다. 셋째, read_image는 조건부 등록의 전형: ctx.attachments가 없으면 등록 자체가 없고, 등록됐어도 라우팅된 모델이 그림을 안 받으면 실행 시 거부(docs/tool-catalog.zh.md 718행). 진화사: edit 결과의 문맥 diff 카드는 처음에 결과 시점에 hunk를 재계산했고(보관 Agent Note .agents/notes/archived/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md), 나중에 백엔드가 before/after 전문을 주고 도구가 hunk를 meta에 넣어 리플레이 재계산을 없앴습니다 — 도구 출력 계약에서 막 다룬 통로입니다.

가로 비교 · 같은 규칙의 세 농도

Claude Code도 읽은 뒤에 쓰기를 강제하며, 규칙을 도구 설명서에 직접 넣습니다. FileEditTool prompt 원문:

claude-code-sourcemap-main/study/chapters/14-all-prompts.md · 1124행 (restored-src/src/tools/FileEditTool/prompt.ts 14–28행 인용)
“You must use your ${FILE_READ_TOOL_NAME} tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.”

“This tool will error”는 CC 런타임이 진짜로 강제 검사한다는 뜻이지, 프롬프트 인사치레가 아닙니다. old_string이 유일하지 않으면 실패하고, 문맥을 늘리거나 replace_all — DSH와 동형입니다. 차이는 방어선 장착 위치: CC는 FileEditTool 자체에, DSH는 독립 플러그인으로 뽑아 read·edit·write·str_replace_editor가 같은 원장을 공유하고 도구 본체에 권한 코드가 한 줄도 없습니다. 외부 변경 후 CC가 진부 읽기를 어떻게 감지하는지는 검토한 원고에 구현 디테일이 없어, 공개 증거 기준으로 보류합니다.

Grok Build의 search_replace 도구에 같은 싸움의 흔적이 남습니다. 설정에 skip_read_before_edit가 있고, 주석은 폐기된 런타임 no-op이라며 설정 시 Read 도구 의존만 검사한다고 합니다 — 읽은 뒤에 쓰기가 한때 하드 스위치였다 풀린 셈입니다. 진부 읽기 처리에서 지향 차이가 더 드러납니다:

grok-build-main/crates/codegen/xai-grok-tools/src/implementations/grok_build/search_replace/mod.rs · 111–113행 (include_user_edit_hint 필드 주석)
“When true, append a hint that the user may have changed the file to NoMatchesFound error messages. This nudges the model to re-read instead of blindly retrying with the same stale content.”

쉽게 말하면: 사람이 파일을 바꿔 매칭이 실패하면 Grok은 오류 문구에 힌트를 붙여 다시 읽으라고 권합니다. 힌트 농도의 방어선으로, 모델 자각에 기대요. DSH는 버전 증표 농도: 버전이 안 맞으면 FS_STALE_VERSION, 물리적으로 쓰기를 막습니다. Grok도 강점이 있습니다 — unicode_normalized_fallback으로 스마트 따옴표·긴 대시처럼 눈에 안 띄는 문자 매칭 실패 시 정규화 재시도(같은 파일 103–110행). DSH edit는 현재 개행 정규화 후 정확 매칭만 합니다. hunk급 변경 추적은 Grok이 xai-hunk-tracker crate로 분리했고, 도구 시스템 전경은 구현 패밀리, 레지스트리와 동적 MCP를 보세요.

수업 실습
01

삼연타 한 번 추론하기

세션이 막 시작됐습니다. 모델이 순서대로: 없는 draft.md를 write, 그 draft.md를 edit, 당신이 에디터에서 draft.md 한 글자를 고친 뒤 모델이 두 번째 edit. 각 호출의 가드(createIfAbsent / replaceIfVersion / 버전 가드)와 결말, 단계마다 원장 상태를 적으세요. 특히 2단계: write 성공이 fs/observed를 emit할까요? 그 기장이 없으면 다음 edit는? (힌트: writeIntent 표에서 present는 replaceIfVersion, edit는 안 읽으면 바로 FS_NOT_OBSERVED.)

Takeaway:DSH에서 읽은 뒤에 쓰기는 관측 원장 plus 버전 증표입니다: 안 읽은 edit는 FS_NOT_OBSERVED, 읽은 뒤 외부 변경은 FS_STALE_VERSION, 판정은 원장만 보고 운은 안 봅니다. 방어선은 탈착형 플러그인이고 도구에 권한 코드가 없습니다. 같은 규칙인데 CC는 도구 자체 런타임 검사에, Grok은 오류 문구의 한 줄 권고로 물러납니다 — 농도 차이가 바로 보입니다.