瀏覽代碼

Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload

creatixchu 3 周之前
父節點
當前提交
2a826a93c3

+ 2 - 2
packages/fs/fs-observation-policy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/fs/fs-observation-policy/README.md
-README.md: 45534acf97f0a8c90172846cb7bc6221caa7a365
-README.zh.md: 72034458c67dfa6c64981167de7cf4cdace3c033
+README.md: c685e279109964bd1161e7586299d958676331fe
+README.zh.md: 79bf0c58e20fa18d9a0ace8a270d86232d3aa6a3

+ 2 - 2
packages/fs/fs-observation-policy/README.md

@@ -43,7 +43,7 @@ With the policy mounted, `write` creates new files but refuses to overwrite an e
 
 ### Failures and recovery
 
-An edit without a prior observation fails with code `FS_NOT_OBSERVED` and message `edit requires reading "<path>" first`; editing a target observed absent fails with `FS_NOT_FOUND`. The tools append the recovery instruction — re-read the file, then retry — while preserving the code. Following the remedy on an externally deleted file records absence, so the next guarded write can recreate it without clobbering a concurrent creator.
+An edit without a prior observation fails with code `FS_NOT_OBSERVED` and policy reason `edit requires reading "<path>" first`; editing a target observed absent fails with `FS_NOT_FOUND`. The tools normalize unread policy and provider failures to `cannot modify "<path>": file has not been read — read the file, then retry` while preserving the code and original cause. Following the remedy on an externally deleted file records absence, so the next guarded write can recreate it without clobbering a concurrent creator.
 
 -----
 
@@ -106,7 +106,7 @@ Read these pages when the package-level contract is not enough. They move from t
 
 #### What the model sees
 
-This plugin adds no prompt or schema. It rejects an edit without a prior observation with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`; editing a target observed absent returns `FS_NOT_FOUND`. Guarded mutations whose positive observation is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code. Following the stale remedy on an externally deleted target records absence: the next guarded write may recreate it with `createIfAbsent`, while the provider atomically preserves any concurrent creator.
+This plugin adds no prompt or schema. It rejects an edit without a prior observation with code `FS_NOT_OBSERVED` and policy reason `edit requires reading "<path>" first`; editing a target observed absent returns `FS_NOT_FOUND`. Guarded mutations whose positive observation is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper: it normalizes every `FS_NOT_OBSERVED` source to `cannot modify "<path>": file has not been read — read the file, then retry`, while `FS_STALE_VERSION` retains the provider reason and adds `— re-read the file, then retry`; both preserve the code and original cause. Following the stale remedy on an externally deleted target records absence: the next guarded write may recreate it with `createIfAbsent`, while the provider atomically preserves any concurrent creator.
 
 #### Token effect
 

+ 2 - 2
packages/fs/fs-observation-policy/README.zh.md

@@ -43,7 +43,7 @@ kind: "package-reference"
 
 ### 失败与恢复
 
-没有先前观测的编辑以代码 `FS_NOT_OBSERVED` 和消息 `edit requires reading "<path>" first` 失败;编辑被观测为缺失的目标以 `FS_NOT_FOUND` 失败。工具会追加恢复指令——先重新读取文件再重试——同时保留错误码。在外部删除的文件上遵循该恢复指令会记录缺失,因此下一次防护写入可以重新创建它,而不会覆盖并发创建者。
+没有先前观测的编辑以代码 `FS_NOT_OBSERVED` 和策略原因 `edit requires reading "<path>" first` 失败;编辑被观测为缺失的目标以 `FS_NOT_FOUND` 失败。工具把策略和提供方的未读失败统一为 `cannot modify "<path>": file has not been read — read the file, then retry`,同时保留错误码和原始原因。在外部删除的文件上遵循该恢复指令会记录缺失,因此下一次防护写入可以重新创建它,而不会覆盖并发创建者。
 
 -----
 
@@ -106,7 +106,7 @@ kind: "package-reference"
 
 #### 模型看到的内容
 
-该插件不添加提示词或 schema。没有先前观测时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝编辑;编辑被观测为缺失的目标返回 `FS_NOT_FOUND`。正向观测陈旧时,带防护的变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.zh.md) 拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码。外部删除目标后,遵循陈旧恢复指令会记录缺失:下一次带防护的写入可以通过 `createIfAbsent` 重新创建该目标,而提供方会以原子方式保留任何并发创建者写入的文件。
+该插件不添加提示词或 schema。没有先前观测时,它会以代码 `FS_NOT_OBSERVED` 和策略原因 `edit requires reading "<path>" first` 拒绝编辑;编辑被观测为缺失的目标返回 `FS_NOT_FOUND`。正向观测陈旧时,带防护的变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.zh.md) 拥有模型侧错误包装:它把所有 `FS_NOT_OBSERVED` 来源规范化为 `cannot modify "<path>": file has not been read — read the file, then retry`,而 `FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`;两者都保留错误码和原始原因。外部删除目标后,遵循陈旧恢复指令会记录缺失:下一次带防护的写入可以通过 `createIfAbsent` 重新创建该目标,而提供方会以原子方式保留任何并发创建者写入的文件。
 
 #### Token 影响
 

+ 4 - 1
packages/fs/fs-observation-policy/tests/policy.spec.ts

@@ -81,7 +81,10 @@ describe('write-intent decision', () => {
 describe('edit-intent decision', () => {
   it('rejects an unread edit with FS_NOT_OBSERVED', async () => {
     const { ctx } = await setup()
-    await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
+    await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({
+      code: 'FS_NOT_OBSERVED',
+      message: 'edit requires reading "a.txt" first',
+    })
   })
 
   it('rejects an edit with no owner (cannot prove prior observation)', async () => {

+ 2 - 2
packages/fs/tool-fs/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md
-README.md: f85c9afc1ed55b8e3b56399d6cfab07c683a501b
-README.zh.md: 124c5b30fbec13c09a4d8f4ca6af19b07a45fb0e
+README.md: 6da984ff436f3515b4798ddb47e3a15466672f9e
+README.zh.md: e4d1ef8483c2e644f2e6308e1965d87773278515

文件差異過大導致無法顯示
+ 2 - 2
packages/fs/tool-fs/README.md


文件差異過大導致無法顯示
+ 2 - 2
packages/fs/tool-fs/README.zh.md


+ 1 - 1
packages/fs/tool-fs/src/edit.ts

@@ -134,7 +134,7 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxController): void
         // A sandbox denial becomes the shared [sandbox: …] marker (the model
         // recognizes it from bash); stale/not-observed failures gain their
         // model-facing remedy; anything else passes through.
-        throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
+        throw remediateFsError(sandbox.mapError(error, sandboxPolicy), target.displayPath)
       }
       ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
       return {

+ 22 - 22
packages/fs/tool-fs/src/error.ts

@@ -1,34 +1,34 @@
 /**
- * Model-facing remediation for guarded-mutation failures. The provider's
- * `FS_STALE_VERSION` and `FS_NOT_OBSERVED` messages state the condition but
- * not the only correct recovery (re-read / read the file), so this package
- * appends the remedy at the model boundary; provider messages stay
- * machine-oriented and unchanged.
+ * Model-facing diagnostics for guarded-mutation failures. Providers and
+ * policies retain operation-specific causes, while this package owns the
+ * stable message shown to the model.
  * @module @deepseek-ai/dsh-tool-fs/src/error
  */
 
 import { FsError } from '@deepseek-ai/dsh-fs'
-import type { FsErrorCode } from '@deepseek-ai/dsh-fs'
-
-/** The remedy appended to each remediable failure code's message. */
-const REMEDIES: Partial<Record<FsErrorCode, string>> = {
-  FS_STALE_VERSION: 're-read the file, then retry',
-  FS_NOT_OBSERVED: 'read the file, then retry',
-}
 
 /**
- * Append the correct recovery instruction to a guarded-mutation failure's
- * message. `FS_STALE_VERSION` (the file changed since this session's last
- * observation, including a missing target) recovers only by re-reading;
- * `FS_NOT_OBSERVED` (no prior read by this session) by reading. The `FsError`
- * code is preserved so retry/permission/UI layers keep routing on it, and the
- * original error chains as `cause`. Anything else passes through untouched.
+ * Render the stable model-facing diagnostic for a guarded-mutation failure.
+ * `FS_STALE_VERSION` keeps the provider's reason and appends its re-read
+ * remedy. `FS_NOT_OBSERVED` replaces operation-specific policy/provider text
+ * with one path-aware reason and read remedy. The original error remains the
+ * cause, and both diagnostics preserve its code for machine routing. Anything
+ * else passes through untouched.
  * @param error - the caught value from a write/edit execution.
+ * @param displayPath - the resolved target path shown to the model.
  * @returns a remediated `FsError` for the two guarded-mutation codes, else the original value.
  */
-export function remediateFsError(error: unknown): unknown {
+export function remediateFsError(error: unknown, displayPath: string): unknown {
   if (!(error instanceof FsError)) return error
-  const remedy = REMEDIES[error.code]
-  if (!remedy) return error
-  return new FsError(`${error.message} — ${remedy}`, error.code, { cause: error })
+  if (error.code === 'FS_NOT_OBSERVED') {
+    return new FsError(
+      `cannot modify "${displayPath}": file has not been read — read the file, then retry`,
+      error.code,
+      { cause: error },
+    )
+  }
+  if (error.code === 'FS_STALE_VERSION') {
+    return new FsError(`${error.message} — re-read the file, then retry`, error.code, { cause: error })
+  }
+  return error
 }

+ 1 - 1
packages/fs/tool-fs/src/write.ts

@@ -115,7 +115,7 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxController): void
         // A sandbox denial becomes the shared [sandbox: …] marker (the model
         // recognizes it from bash); stale/not-observed failures gain their
         // model-facing remedy; anything else passes through.
-        throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
+        throw remediateFsError(sandbox.mapError(error, sandboxPolicy), target.displayPath)
       }
       ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
       return {

+ 15 - 7
packages/fs/tool-fs/tests/error.spec.ts

@@ -10,26 +10,34 @@ import { remediateFsError } from '../src/error.ts'
 describe('remediateFsError', () => {
   it('appends the re-read remedy to FS_STALE_VERSION, preserving the code and chaining the cause', () => {
     const original = new FsError('cannot edit "x": file changed since it was read', 'FS_STALE_VERSION')
-    const remedied = remediateFsError(original) as FsError
+    const remedied = remediateFsError(original, 'x') as FsError
     expect(remedied).toBeInstanceOf(FsError)
     expect(remedied.message).toBe('cannot edit "x": file changed since it was read — re-read the file, then retry')
     expect(remedied.code).toBe('FS_STALE_VERSION')
     expect(remedied.cause).toBe(original)
   })
 
-  it('appends the read remedy to FS_NOT_OBSERVED', () => {
-    const remedied = remediateFsError(new FsError('edit requires reading "x" first', 'FS_NOT_OBSERVED')) as FsError
-    expect(remedied.message).toBe('edit requires reading "x" first — read the file, then retry')
-    expect(remedied.code).toBe('FS_NOT_OBSERVED')
+  it('normalizes policy and provider FS_NOT_OBSERVED failures to one diagnostic', () => {
+    const sources = [
+      new FsError('edit requires reading "x" first', 'FS_NOT_OBSERVED'),
+      new FsError('cannot overwrite existing "x" without reading it first', 'FS_NOT_OBSERVED'),
+    ]
+    const remedied = sources.map(error => remediateFsError(error, 'x') as FsError)
+    expect(remedied.map(error => error.message)).toEqual([
+      'cannot modify "x": file has not been read — read the file, then retry',
+      'cannot modify "x": file has not been read — read the file, then retry',
+    ])
+    expect(remedied.map(error => error.code)).toEqual(['FS_NOT_OBSERVED', 'FS_NOT_OBSERVED'])
+    expect(remedied.map(error => error.cause)).toEqual(sources)
   })
 
   it('leaves other FsError codes untouched', () => {
     const original = new FsError('no match anywhere', 'FS_EDIT_NOT_FOUND')
-    expect(remediateFsError(original)).toBe(original)
+    expect(remediateFsError(original, 'x')).toBe(original)
   })
 
   it('leaves non-FsError values untouched', () => {
     const original = new Error('boom')
-    expect(remediateFsError(original)).toBe(original)
+    expect(remediateFsError(original, 'x')).toBe(original)
   })
 })

+ 6 - 6
packages/fs/tool-fs/tests/integration.spec.ts

@@ -40,6 +40,10 @@ function text(result: { content: { type: string; text?: string }[] }): string {
   return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
 }
 
+function notObservedDiagnostic(path: string): string {
+  return `Error: cannot modify "${path}": file has not been read — read the file, then retry`
+}
+
 afterEach(async () => {
   await fiber.dispose()
   await rm(dir, { recursive: true, force: true })
@@ -71,9 +75,7 @@ describe('default deployment (with dsh-fs-observation-policy)', () => {
       const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
       expect(result.isError).toBe(true)
       expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
-      // The model-facing text names the remedy, not just the condition.
-      expect(text(result)).toContain('without reading it first')
-      expect(text(result)).toContain('read the file, then retry')
+      expect(text(result)).toBe(notObservedDiagnostic(join(dir, 'a.txt')))
       expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
     })
 
@@ -151,9 +153,7 @@ describe('default deployment (with dsh-fs-observation-policy)', () => {
       const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
       expect(result.isError).toBe(true)
       expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
-      // The policy's refusal reaches the model with the read remedy appended.
-      expect(text(result)).toContain('edit requires reading')
-      expect(text(result)).toContain('read the file, then retry')
+      expect(text(result)).toBe(notObservedDiagnostic(join(dir, 'a.txt')))
       expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
     })
 

+ 2 - 2
snapshots/session/fs-policy-reject/session.jsonl

@@ -21,7 +21,7 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}
-{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first — read the file, then retry"}],"isError":true}],"role":"user","id":"{{message:4}}"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[85],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: cannot modify \"{{cwd}}/settings.txt\": file has not been read — read the file, then retry"}],"isError":true}],"role":"user","id":"{{message:4}}"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[85],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"step/start","data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -34,7 +34,7 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}
-{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first — read the file, then retry"}],"isError":true}],"role":"user","id":"{{message:6}}"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[166],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"Error: cannot modify \"{{cwd}}/settings.txt\": file has not been read — read the file, then retry"}],"isError":true}],"role":"user","id":"{{message:6}}"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[166],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":2}}
 {"type":"step/start","data":{"turn":1,"step":3}}
 {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

部分文件因文件數量過多而無法顯示