mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
149 lines
6.6 KiB
TypeScript
149 lines
6.6 KiB
TypeScript
/**
|
|
* 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 }
|
|
},
|
|
}))
|
|
}
|