2026-06-22 10:48:41 +08:00
/**
2026-07-13 23:27:00 +08:00
* 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.
2026-06-28 17:43:29 +08:00
* @module @deepseek-ai/dsh-tool-fs/src/write
2026-06-22 10:48:41 +08:00
*/
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
2026-07-03 17:12:00 +08:00
import type { DiffCallView , DiffResultView , ToolResult } from '@deepseek-ai/dsh-tools'
2026-06-22 10:48:41 +08:00
import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
2026-06-28 13:49:02 +08:00
import type { } from '@deepseek-ai/dsh-fs'
2026-06-22 10:48:41 +08:00
import type { } from '@deepseek-ai/dsh-system-prompt'
2026-07-21 03:08:35 +08:00
import { computeHunkDiffs , diffsFromMeta } from './diff.ts'
2026-08-03 19:49:30 +08:00
import { remediateFsError } from './error.ts'
2026-07-14 19:50:25 +08:00
import { sessionResolveOptions } from './session-cwd.ts'
2026-07-14 20:05:57 +08:00
import type { FsSandboxSurface } from './sandbox.ts'
2026-06-22 10:48:41 +08:00
2026-07-06 22:09:30 +08:00
/**
* 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.
*/
2026-06-22 10:48:41 +08:00
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 }
}
2026-07-06 22:09:30 +08:00
/**
* 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).
*/
2026-07-21 03:08:35 +08:00
export function formatWriteOutput ( displayPath : string , outcome : Pick < FsWriteOutcome , 'operation' > ) : string {
2026-06-22 10:48:41 +08:00
const verb = outcome . operation === 'create' ? 'Created' : 'Updated'
return ` <path> ${ displayPath } </path>
<type>file</type>
<content>
${ verb } file
</content> `
}
2026-07-14 20:05:57 +08:00
/**
* 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
}
2026-07-06 22:09:30 +08:00
/**
* 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.
2026-07-14 20:05:57 +08:00
* @param sandbox - the shared sandbox-escalation surface (advertisement, mode stamping, denial mapping).
2026-07-06 22:09:30 +08:00
*/
2026-07-14 20:05:57 +08:00
export function applyWriteTool ( ctx : Context , sandbox : FsSandboxSurface ) : void {
2026-06-22 10:48:41 +08:00
ctx . systemPrompt . section ( {
name : 'tool:write' ,
order : 101 ,
2026-07-02 03:12:38 +08:00
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.' ,
2026-06-22 10:48:41 +08:00
} )
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.' } ,
2026-07-14 20:05:57 +08:00
. . . sandbox . escalationModes . length > 0 ? sandbox . schemaFields ( ) : { } ,
2026-06-22 10:48:41 +08:00
} ,
2026-07-21 03:08:35 +08:00
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 ) {
2026-06-22 10:48:41 +08:00
const input = parseWriteArgs ( args )
2026-07-21 00:44:28 +08:00
// 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 )
2026-07-21 20:49:14 +08:00
const target = await ctx . fs . resolve ( input . filePath , sessionResolveOptions ( exec , input . filePath , sandboxPolicy ? . workspaceRoot ) )
2026-06-28 13:49:02 +08:00
// Single-slot decision: the policy plugin produces createIfAbsent/
// replaceIfVersion; the bare default is undefined (unconditional). No stat.
2026-07-02 03:12:38 +08:00
const intent = await ctx . waterfall ( 'fs/write-intent' , target , exec , ( ) = > undefined )
2026-07-14 20:05:57 +08:00
let outcome : FsWriteOutcome
try {
2026-07-21 00:44:28 +08:00
outcome = await ctx . fs . writeText ( target , input . content , intent , exec . signal , sandboxPolicy )
2026-07-14 20:05:57 +08:00
} catch ( error : unknown ) {
// A sandbox denial becomes the shared [sandbox: …] marker (the model
2026-08-03 19:49:30 +08:00
// recognizes it from bash); stale/not-observed failures gain their
// model-facing remedy; anything else passes through.
throw remediateFsError ( sandbox . mapError ( error , sandboxPolicy ) )
2026-07-14 20:05:57 +08:00
}
2026-06-29 10:34:08 +08:00
// Record the observed version (a no-op when no policy plugin listens).
ctx . emit ( 'fs/observed' , target , outcome . version , exec )
2026-07-03 17:12:00 +08:00
return {
2026-07-21 03:08:35 +08:00
path : target.displayPath ,
operation : outcome.operation ,
before : outcome.before ,
after : outcome.after ,
2026-07-03 17:12:00 +08:00
}
2026-06-22 10:48:41 +08:00
} ,
2026-07-24 01:40:25 +08:00
// Pure display: a diff card. A call-time presenter has no access to prior
// file content, so `oldText: null` also represents an overwrite here.
2026-07-03 02:04:03 +08:00
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 } ] ,
}
2026-07-02 19:36:17 +08:00
} ,
2026-07-24 01:40:25 +08:00
// 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.
2026-07-03 17:12:00 +08:00
presentResult ( args , result : ToolResult ) : DiffResultView | undefined {
if ( result . isError ) return undefined
const diffs = diffsFromMeta ( result . meta )
2026-07-03 20:52:36 +08:00
? ? [ { path : args.file_path , oldText : null , newText : args.content } ]
2026-07-03 17:12:00 +08:00
return { card : 'diff' , title : ` Write ${ args . file_path } ` , diffs }
} ,
2026-06-22 10:48:41 +08:00
} ) )
}