2026-06-22 10:48:41 +08:00
/**
2026-06-28 13:49:02 +08:00
* The model-facing `write` tool: create or fully replace a UTF-8 text file. The
2026-07-02 03:12:38 +08:00
* tool is the executor: it dispatches the `fs/write-intent` waterfall to
2026-06-28 13:49:02 +08:00
* obtain the optional version guard, calls `ctx.fs.writeText` directly, and
2026-06-29 10:34:08 +08:00
* emits `fs/observed`. The default thunk returns `undefined` (unconditional
* create-or-overwrite — the bare provider); a policy plugin
2026-07-02 03:12:38 +08:00
* (`@deepseek-ai/dsh-fs-policy`) occupies the single decision slot and
2026-06-28 13:49:02 +08:00
* returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO
* times either way.
2026-06-22 10:48:41 +08:00
*
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 { ContentBlock } from '@deepseek-ai/dsh-llm'
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-03 17:12:00 +08:00
import { computeHunkDiffs , diffsFromMeta , type FsDiffMeta } from './diff.ts'
2026-07-02 18:29:29 +08:00
import { sessionCwd } from './session-cwd.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-06-22 10:48:41 +08:00
export function formatWriteOutput ( displayPath : string , outcome : FsWriteOutcome ) : string {
const verb = outcome . operation === 'create' ? 'Created' : 'Updated'
return ` <path> ${ displayPath } </path>
<type>file</type>
<content>
${ verb } file
</content> `
}
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-06-28 17:43:29 +08:00
export function applyWriteTool ( ctx : Context ) : 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-03 17:12:00 +08:00
async execute ( args , exec ) : Promise < { content : ContentBlock [ ] ; meta? : FsDiffMeta } > {
2026-06-22 10:48:41 +08:00
const input = parseWriteArgs ( args )
2026-07-02 18:29:29 +08:00
const cwd = sessionCwd ( exec )
2026-07-13 16:31:03 +08:00
const target = await ctx . fs . resolve ( input . filePath , {
. . . cwd !== undefined ? { cwd } : { } ,
. . . exec . signal !== undefined ? { signal : exec.signal } : { } ,
} )
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 )
const outcome = await ctx . fs . writeText ( target , input . content , intent , exec . signal )
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 21:26:50 +08:00
// Attach a contextual hunk as `meta` ONLY for an overwrite (a before-version
2026-07-03 17:12:00 +08:00
// exists). A create has no "before" — `outcome.before` is null — so it
2026-07-03 21:26:50 +08:00
// carries no `meta`; `presentResult` then renders a whole-file diff from the
// args, so the completed card is still a diff (never the result text).
2026-07-03 17:12:00 +08:00
const diffs = outcome . before !== null ? computeHunkDiffs ( input . filePath , outcome . before , outcome . after ) : [ ]
return {
content : [ { type : 'text' , text : formatWriteOutput ( target . displayPath , outcome ) } ] ,
. . . diffs . length > 0 ? { meta : { diffs } } : { } ,
}
2026-06-22 10:48:41 +08:00
} ,
2026-07-03 02:04:03 +08:00
// Pure display: a diff card (an editor renders write as a new-file / full-
// replace diff). `oldText: null` — a call-time presenter has no access to the
// file's prior content, so even an overwrite renders new-file style, matching
// claude-agent-acp. A follow-along location points at the written file.
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-03 20:52:36 +08:00
// Result-time display: a `diff` card so the completed `tool_call_update`
// re-installs the diff rather than the model-facing result text (an ACP
// `tool_call_update.content` REPLACES the call's content, so a text result
// would clobber the pending diff card). An OVERWRITE uses the applied
// contextual hunks on `meta`; a CREATE has no `meta` (no prior content), so
// its whole-file new-file diff is derived from `args.content` (replay-safe,
// matching the call-time card). An error falls through to generic rendering
// so its message shows.
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
} ) )
}