/** * Model-facing full-file write. It obtains an optional intent from the single policy slot, calls * `ctx.fs.writeText` without a stat, then records the resulting version; no policy means an * unconditional atomic create-or-overwrite. * @module @deepseek-ai/dsh-tool-fs/src/write */ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' 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 { sessionResolveOptions } from './session-cwd.ts' import type { FsSandboxSurface } from './sandbox.ts' /** * Validate value constraints the schema DSL can't express: only a non-blank * `file_path` — an empty `content` is legitimate (it writes an empty file). * @param args - the schema-validated raw tool arguments. * @returns the camelCased input; `content` passes through untouched. */ export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } { if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') return { filePath: args.file_path, content: args.content } } /** * Format a write outcome as one model-facing text block body. * @param displayPath - the backend-resolved path rendered in the envelope's `` element. * @param outcome - the write outcome; its `operation` selects the Created/Updated wording. * @returns the model-facing confirmation envelope (no file content is echoed back). */ export function formatWriteOutput(displayPath: string, outcome: Pick): string { const verb = outcome.operation === 'create' ? 'Created' : 'Updated' return `${displayPath} file ${verb} file ` } /** * The `write` tool's validated argument shape: the base parameters plus the * two escalation fields, advertised only under a confining `ctx.fs` (absent * from the schema otherwise, so the validator rejects them before `execute`). */ interface WriteToolArgs { file_path: string content: string sandbox_permissions?: string justification?: string } /** * Register the `write` tool and its system-prompt guidance. * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service. * @param sandbox - the shared sandbox-escalation surface (advertisement, mode stamping, denial mapping). */ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void { ctx.systemPrompt.section({ name: 'tool:write', order: 101, text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.', }) ctx.tools.register(defineTool({ name: 'write', description: 'Create or fully replace a UTF-8 text file.', parameters: { file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' }, content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' }, ...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}, }, output: { schema: { type: 'object', additionalProperties: false, properties: { path: { type: 'string', required: true }, operation: { type: 'string', required: true, enum: ['create', 'update'] }, before: { required: true, oneOf: [ { type: 'string' }, { type: 'null' }, ], }, after: { type: 'string', required: true }, }, }, render: (_args, value) => [{ type: 'text', text: formatWriteOutput(value.path, value) }], presentationMeta: (args, value) => ({ diffs: value.before === null ? [] : computeHunkDiffs(args.file_path, value.before, value.after) .map(({ path, oldText, newText }) => ({ path, oldText, newText })), }), }, async execute(args: WriteToolArgs, exec) { const input = parseWriteArgs(args) // Resolve the per-call sandbox policy (approved mode > session override // > backend default, plus the session cwd root) BEFORE anything executes; // an escalating call throws its distinct text on any non-grant. const sandboxPolicy = await sandbox.resolvePolicy('write', args, exec) const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot)) // Single-slot decision: the policy plugin produces createIfAbsent/ // replaceIfVersion; the bare default is undefined (unconditional). No stat. const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined) let outcome: FsWriteOutcome try { 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) } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) return { path: target.displayPath, operation: outcome.operation, before: outcome.before, after: outcome.after, } }, // Pure display: a diff card. A call-time presenter has no access to prior // file content, so `oldText: null` also represents an overwrite here. presentCall(args): DiffCallView { return { card: 'diff', title: `Write ${args.file_path}`, diffs: [{ path: args.file_path, oldText: null, newText: args.content }], locations: [{ path: args.file_path }], } }, // Result-time display repeats the diff because completed views replace the // pending view. Overwrites use applied metadata; creates and identical // overwrites use the replay-safe args fallback. presentResult(args, result: ToolResult): DiffResultView | undefined { if (result.isError) return undefined const diffs = diffsFromMeta(result.meta) ?? [{ path: args.file_path, oldText: null, newText: args.content }] return { card: 'diff', title: `Write ${args.file_path}`, diffs } }, })) }