| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148 |
- /**
- * 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 `<path>` 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<FsWriteOutcome, 'operation'>): string {
- const verb = outcome.operation === 'create' ? 'Created' : 'Updated'
- return `<path>${displayPath}</path>
- <type>file</type>
- <content>
- ${verb} file
- </content>`
- }
- /**
- * 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 }
- },
- }))
- }
|