feat(fs): append recovery remedy to guarded-mutation errors
write/edit failures with FS_STALE_VERSION or FS_NOT_OBSERVED now reach the model with the correct recovery instruction appended (re-read / read, then retry) while preserving the structured code and chaining the cause. The edit-intent waterfall sits inside the same try, so the policy's FS_NOT_OBSERVED refusal is remediated too. Re-recorded the fs-policy-reject keyless snapshot and the bilingual README pairs.
This commit is contained in:
@@ -11,6 +11,7 @@ import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
|
||||
import { remediateFsError } from './error.ts'
|
||||
import { sessionResolveOptions } from './session-cwd.ts'
|
||||
import type { FsSandboxSurface } from './sandbox.ts'
|
||||
|
||||
@@ -116,10 +117,13 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))
|
||||
// Single-slot decision: the policy plugin returns { version: vObserved } or
|
||||
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
|
||||
// No stat — the bare default never manufactures a version basis.
|
||||
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
|
||||
// No stat — the bare default never manufactures a version basis. The intent
|
||||
// slot itself can throw FS_NOT_OBSERVED for an unread target, so it sits
|
||||
// inside the try: both that refusal and the provider's guarded-mutation
|
||||
// failure get the model-facing remedy below.
|
||||
let outcome
|
||||
try {
|
||||
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
|
||||
outcome = await ctx.fs.editText(
|
||||
target,
|
||||
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
|
||||
@@ -128,8 +132,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
sandboxPolicy,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through.
|
||||
throw sandbox.mapError(error, sandboxPolicy)
|
||||
// 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))
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
|
||||
34
packages/fs/tool-fs/src/error.ts
Normal file
34
packages/fs/tool-fs/src/error.ts
Normal file
@@ -0,0 +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.
|
||||
* @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.
|
||||
* @param error - the caught value from a write/edit execution.
|
||||
* @returns a remediated `FsError` for the two guarded-mutation codes, else the original value.
|
||||
*/
|
||||
export function remediateFsError(error: unknown): 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 })
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
|
||||
import { remediateFsError } from './error.ts'
|
||||
import { sessionResolveOptions } from './session-cwd.ts'
|
||||
import type { FsSandboxSurface } from './sandbox.ts'
|
||||
|
||||
@@ -113,8 +114,9 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy)
|
||||
} catch (error: unknown) {
|
||||
// A sandbox denial becomes the shared [sandbox: …] marker (the model
|
||||
// recognizes it from bash); any other error passes through.
|
||||
throw sandbox.mapError(error, sandboxPolicy)
|
||||
// recognizes it from bash); stale/not-observed failures gain their
|
||||
// model-facing remedy; anything else passes through.
|
||||
throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
|
||||
Reference in New Issue
Block a user