2026-07-29 14:12:27 +08:00
/**
* Model-facing `str_replace_editor` over the Harness filesystem seam.
* @module @deepseek-ai/dsh-tool-str-replace-editor
*/
import { isAbsolute } from 'node:path'
2026-08-10 22:04:06 +08:00
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
2026-07-29 14:12:27 +08:00
import { FsError } from '@deepseek-ai/dsh-fs'
2026-07-29 15:21:56 +08:00
import type { FsInfo , FsTarget , FsWriteIntent } from '@deepseek-ai/dsh-fs'
import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
2026-07-29 14:12:27 +08:00
import { defineTool } from '@deepseek-ai/dsh-tools'
2026-07-29 15:21:56 +08:00
import type { ToolCallView , ToolRunContext } from '@deepseek-ai/dsh-tools'
2026-07-29 14:12:27 +08:00
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
const DEFAULT_DESCRIPTION = `
Custom editing tool for viewing, creating and editing files
* State is persistent across command calls and discussions with the user
* If \` path \` is a file, \` view \` displays the result of applying \` cat -n \` . If \` path \` is a directory, \` view \` lists non-hidden files and directories up to 2 levels deep
* The \` create \` command cannot be used if the specified \` path \` already exists as a file
* If a \` command \` generates a long output, it will be truncated and marked with \` <response clipped> \`
Notes for using the \` str_replace \` command:
* The \` old_str \` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
* If the \` old_str \` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in \` old_str \` to make it unique
* The \` new_str \` parameter should contain the edited lines that should replace the \` old_str \`
` . trim ( )
function maybeTruncate ( content : string , maxOutputChars : number ) : string {
return content . length <= maxOutputChars
? content
: content.slice ( 0 , maxOutputChars ) + TRUNCATED_MESSAGE
}
2026-07-29 15:21:56 +08:00
function codepointCompare ( left : string , right : string ) : number {
return left < right ? - 1 : left > right ? 1 : 0
}
function matchOffsets ( content : string , search : string ) : number [ ] {
const offsets : number [ ] = [ ]
let offset = 0
while ( true ) {
const match = content . indexOf ( search , offset )
if ( match < 0 ) return offsets
offsets . push ( match )
offset = match + search . length
}
}
function lineNumbersAt ( content : string , offsets : readonly number [ ] ) : number [ ] {
let line = 1
let cursor = 0
return offsets . map ( ( offset ) = > {
while ( cursor < offset ) {
if ( content [ cursor ] === '\n' ) line += 1
cursor += 1
}
return line
} )
}
class MutationPolicy {
private readonly policy : SandboxPolicyService | undefined
constructor ( ctx : Context ) {
this . policy = ctx . fs . sandboxMode === undefined ? undefined : ctx . get ( 'sandboxPolicy' )
if ( ctx . fs . sandboxMode !== undefined && this . policy === undefined ) {
throw new Error ( 'tool-str-replace-editor: the mounted filesystem confines but ctx.sandboxPolicy is missing' )
}
}
resolve ( exec : ToolRunContext ) : SandboxExecutionPolicy | undefined {
return this . policy ? . resolve ( {
. . . exec . agent === undefined ? { } : { session : exec.agent.session } ,
} )
}
mapError ( error : unknown , policy : SandboxExecutionPolicy | undefined ) : unknown {
if ( ! ( error instanceof FsError ) || error . code !== 'FS_SANDBOX_DENIED' ) return error
const mode = ( policy as SandboxExecutionPolicy ) . mode
return new FsError ( sandboxDenialMarker ( mode ) , 'FS_SANDBOX_DENIED' , { cause : error } )
}
}
2026-07-29 14:12:27 +08:00
async function resolveTarget (
ctx : Context ,
path : string ,
2026-07-29 23:50:52 +08:00
signal : AbortSignal ,
2026-07-29 14:12:27 +08:00
) : Promise < FsTarget > {
if ( path . trim ( ) . length === 0 ) throw new Error ( 'path must be a non-empty string' )
2026-07-29 21:37:12 +08:00
if ( ! isAbsolute ( path ) ) {
2026-07-29 14:12:27 +08:00
throw new Error ( ` The path ${ path } is not an absolute path, it should start with \` / \` . Maybe you meant / ${ path } ? ` )
}
2026-07-29 23:50:52 +08:00
return ctx . fs . resolve ( path , { signal } )
2026-07-29 14:12:27 +08:00
}
async function statExisting (
ctx : Context ,
target : FsTarget ,
command : 'view' | 'str_replace' | 'insert' ,
exec : ToolRunContext ,
) : Promise < FsInfo > {
const info = await ctx . fs . stat ( target , exec . signal )
if ( info === undefined ) {
2026-08-09 15:22:50 +08:00
ctx . emit ( 'fs/observed' , target , { kind : 'absent' } , exec )
2026-07-29 14:12:27 +08:00
throw new FsError (
` The path ${ target . displayPath } does not exist. Please provide a valid path. ` ,
'FS_NOT_FOUND' ,
)
}
if ( info . type === 'directory' && command !== 'view' ) {
throw new FsError (
` The path ${ target . displayPath } is a directory and only the \` view \` command can be used on directories ` ,
'FS_NOT_REGULAR_FILE' ,
)
}
return info
}
function requiredForCommand (
value : string | undefined ,
parameter : string ,
command : string ,
allowEmpty = true ,
) : string {
if ( value === undefined ) throw new Error ( ` Parameter \` ${ parameter } \` is required for command: ${ command } ` )
if ( ! allowEmpty && value . length === 0 ) {
throw new Error ( ` Parameter \` ${ parameter } \` is empty for command: ${ command } ` )
}
return value
}
function formatFileView (
path : string ,
content : string ,
maxOutputChars : number ,
viewRange? : number [ ] ,
) : string {
const allLines = content . split ( '\n' )
let lines = allLines
let initialLine = 1
let finalLine : number | undefined
let prompt = ` Here's the content of ${ path } with line numbers (which has a total of ${ allLines . length } lines) `
if ( viewRange !== undefined ) {
const [ requestedInitialLine , requestedFinalLine ] = viewRange
if (
viewRange . length !== 2
|| requestedInitialLine === undefined
|| requestedFinalLine === undefined
|| ! viewRange . every ( Number . isInteger )
) {
throw new Error ( 'Invalid `view_range`. It should be a list of two integers.' )
}
initialLine = requestedInitialLine
finalLine = requestedFinalLine
if ( initialLine < 1 || initialLine > allLines . length ) {
throw new Error (
` Invalid \` view_range \` : [ ${ viewRange . join ( ', ' ) } ]. Its first element \` ${ initialLine } \` should be within the range of lines of the file: [1, ${ allLines . length } ] ` ,
)
}
if ( finalLine > allLines . length ) {
throw new Error (
` Invalid \` view_range \` : [ ${ viewRange . join ( ', ' ) } ]. Its second element \` ${ finalLine } \` should be smaller than the number of lines in the file: \` ${ allLines . length } \` ` ,
)
}
if ( finalLine !== - 1 && finalLine < initialLine ) {
throw new Error (
` Invalid \` view_range \` : [ ${ viewRange . join ( ', ' ) } ]. Its second element \` ${ finalLine } \` should be larger or equal than its first \` ${ initialLine } \` ` ,
)
}
lines = finalLine === - 1
? allLines . slice ( initialLine - 1 )
: allLines . slice ( initialLine - 1 , finalLine )
prompt += ` with view_range=[ ${ initialLine } , ${ finalLine } ] `
}
2026-07-30 00:05:58 +08:00
const numbered = lines
. map ( ( line , index ) = > ` ${ String ( initialLine + index ) . padStart ( 6 , ' ' ) } ${ line } ` )
. join ( '\n' )
2026-07-29 14:12:27 +08:00
return maybeTruncate ( ` ${ prompt } : \ n ${ numbered } \ n ` , maxOutputChars )
}
async function listDirectory (
ctx : Context ,
target : FsTarget ,
maxOutputChars : number ,
exec : ToolRunContext ,
) : Promise < string > {
async function visit ( dir : FsTarget , depth : number ) : Promise < string [ ] > {
const entries = await ctx . fs . listDir ( dir , exec . signal )
const rows : string [ ] = [ ]
for ( const entry of entries . filter ( candidate = >
! candidate . name . startsWith ( '.' )
2026-07-29 15:21:56 +08:00
&& candidate . name !== 'node_modules'
&& candidate . name !== '__pycache__' ) ) {
2026-07-29 14:12:27 +08:00
const type = entry . type === 'directory' ? 'd' : entry . type === 'file' ? 'f' : '?'
rows . push ( ` ${ type } \ t ${ entry . target . displayPath } ` )
if ( entry . type === 'directory' && depth < 2 ) {
rows . push ( . . . await visit ( entry . target , depth + 1 ) )
}
}
return rows
}
const rows = [ ` d \ t ${ target . displayPath } ` , . . . await visit ( target , 1 ) ]
rows . sort ( ( left , right ) = > {
const leftPath = left . slice ( left . indexOf ( '\t' ) + 1 )
const rightPath = right . slice ( right . indexOf ( '\t' ) + 1 )
2026-07-29 15:21:56 +08:00
return codepointCompare ( leftPath , rightPath )
2026-07-29 14:12:27 +08:00
} )
const listing = maybeTruncate ( rows . join ( '\n' ) + '\n' , maxOutputChars )
return ` Here're the files and directories up to 2 levels deep in ${ target . displayPath } , excluding hidden items, node_modules, and Python cache directories: \ n ${ listing } \ n `
}
async function viewPath (
ctx : Context ,
path : string ,
viewRange : number [ ] | undefined ,
maxOutputChars : number ,
exec : ToolRunContext ,
) : Promise < string > {
2026-07-29 23:50:52 +08:00
const target = await resolveTarget ( ctx , path , exec . signal )
2026-07-29 14:12:27 +08:00
const info = await statExisting ( ctx , target , 'view' , exec )
if ( info . type === 'directory' ) {
if ( viewRange !== undefined ) {
throw new Error ( 'The `view_range` parameter is not allowed when `path` points to a directory.' )
}
return listDirectory ( ctx , target , maxOutputChars , exec )
}
if ( info . type !== 'file' ) {
throw new FsError ( ` cannot view " ${ target . displayPath } ": not a regular file or directory ` , 'FS_NOT_REGULAR_FILE' )
}
const content = await ctx . fs . readText ( target , exec . signal )
2026-08-09 15:22:50 +08:00
ctx . emit ( 'fs/observed' , target , { kind : 'present' , version : info.version } , exec )
2026-07-29 14:12:27 +08:00
return formatFileView ( target . displayPath , content , maxOutputChars , viewRange )
}
async function createFile (
ctx : Context ,
2026-07-29 15:21:56 +08:00
policy : MutationPolicy ,
2026-07-29 14:12:27 +08:00
path : string ,
fileText : string | undefined ,
exec : ToolRunContext ,
) : Promise < string > {
const content = requiredForCommand ( fileText , 'file_text' , 'create' )
2026-07-29 15:21:56 +08:00
const sandboxPolicy = policy . resolve ( exec )
2026-07-29 23:50:52 +08:00
const target = await resolveTarget ( ctx , path , exec . signal )
2026-07-29 14:12:27 +08:00
if ( await ctx . fs . stat ( target , exec . signal ) !== undefined ) {
throw new Error ( ` File already exists at: ${ target . displayPath } . Cannot overwrite files using command \` create \` . ` )
}
2026-07-29 15:21:56 +08:00
const intent = await ctx . waterfall (
'fs/write-intent' ,
target ,
exec ,
( ) = > ( { kind : 'createIfAbsent' } as const ) ,
)
let outcome
try {
outcome = await ctx . fs . writeText (
target ,
content ,
2026-07-29 15:33:46 +08:00
intent ,
2026-07-29 15:21:56 +08:00
exec . signal ,
sandboxPolicy ,
)
} catch ( error : unknown ) {
throw policy . mapError ( error , sandboxPolicy )
}
2026-08-09 15:22:50 +08:00
ctx . emit ( 'fs/observed' , target , { kind : 'present' , version : outcome.version } , exec )
2026-07-29 14:12:27 +08:00
return ` New file created successfully at: ${ target . displayPath } `
}
async function replaceInFile (
ctx : Context ,
2026-07-29 15:21:56 +08:00
policy : MutationPolicy ,
2026-07-29 14:12:27 +08:00
path : string ,
oldStr : string | undefined ,
newStr : string | undefined ,
exec : ToolRunContext ,
) : Promise < string > {
2026-07-29 15:21:56 +08:00
const sandboxPolicy = policy . resolve ( exec )
2026-07-29 23:50:52 +08:00
const target = await resolveTarget ( ctx , path , exec . signal )
2026-07-29 15:21:56 +08:00
const intent = await ctx . waterfall ( 'fs/edit-intent' , target , exec , ( ) = > undefined )
2026-07-29 21:25:56 +08:00
const oldValue = requiredForCommand ( oldStr , 'old_str' , 'str_replace' , false )
const newValue = newStr ? ? ''
2026-07-29 14:12:27 +08:00
const info = await statExisting ( ctx , target , 'str_replace' , exec )
if ( info . type !== 'file' ) {
throw new FsError ( ` cannot edit " ${ target . displayPath } ": not a regular file ` , 'FS_NOT_REGULAR_FILE' )
}
2026-07-29 21:25:56 +08:00
const before = await ctx . fs . readText ( target , exec . signal )
2026-07-29 15:21:56 +08:00
const offsets = matchOffsets ( before , oldValue )
2026-07-29 23:48:43 +08:00
const offset = offsets [ 0 ]
if ( offset === undefined ) {
2026-07-29 14:12:27 +08:00
throw new FsError (
` No replacement was performed, old_str \` ${ oldValue } \` did not appear verbatim in ${ target . displayPath } . ` ,
'FS_EDIT_NOT_FOUND' ,
)
}
2026-07-29 15:21:56 +08:00
if ( offsets . length > 1 ) {
const lines = lineNumbersAt ( before , offsets )
2026-07-29 14:12:27 +08:00
throw new FsError (
` No replacement was performed. Multiple occurrences of old_str \` ${ oldValue } \` in lines [ ${ lines . join ( ', ' ) } ]. Please ensure it is unique ` ,
'FS_AMBIGUOUS_EDIT' ,
)
}
2026-07-29 15:21:56 +08:00
let outcome
try {
2026-07-29 21:37:12 +08:00
outcome = await ctx . fs . writeText (
2026-07-29 21:25:56 +08:00
target ,
2026-07-29 23:48:43 +08:00
before . slice ( 0 , offset ) + newValue + before . slice ( offset + oldValue . length ) ,
2026-07-29 21:37:12 +08:00
intent === undefined
? { kind : 'replaceIfVersion' , version : info.version }
: { kind : 'replaceIfVersion' , version : intent.version } ,
2026-07-29 21:25:56 +08:00
exec . signal ,
sandboxPolicy ,
)
2026-07-29 15:21:56 +08:00
} catch ( error : unknown ) {
throw policy . mapError ( error , sandboxPolicy )
}
2026-08-09 15:22:50 +08:00
ctx . emit ( 'fs/observed' , target , { kind : 'present' , version : outcome.version } , exec )
2026-07-29 14:12:27 +08:00
return ` The file ${ target . displayPath } has been edited successfully. `
}
async function insertInFile (
ctx : Context ,
2026-07-29 15:21:56 +08:00
policy : MutationPolicy ,
2026-07-29 14:12:27 +08:00
path : string ,
insertLine : number | undefined ,
newStr : string | undefined ,
exec : ToolRunContext ,
) : Promise < string > {
if ( insertLine === undefined ) throw new Error ( 'Parameter `insert_line` is required for command: insert' )
2026-07-29 21:25:56 +08:00
const value = requiredForCommand ( newStr , 'new_str' , 'insert' )
2026-07-29 15:21:56 +08:00
const sandboxPolicy = policy . resolve ( exec )
2026-07-29 23:50:52 +08:00
const target = await resolveTarget ( ctx , path , exec . signal )
2026-07-29 15:21:56 +08:00
const intent = await ctx . waterfall ( 'fs/edit-intent' , target , exec , ( ) = > undefined )
2026-07-29 14:12:27 +08:00
const info = await statExisting ( ctx , target , 'insert' , exec )
if ( info . type !== 'file' ) {
throw new FsError ( ` cannot insert into " ${ target . displayPath } ": not a regular file ` , 'FS_NOT_REGULAR_FILE' )
}
2026-07-29 21:25:56 +08:00
const before = await ctx . fs . readText ( target , exec . signal )
2026-07-29 14:12:27 +08:00
const lines = before . split ( '\n' )
if ( ! Number . isInteger ( insertLine ) || insertLine < 0 || insertLine > lines . length ) {
throw new Error (
` Invalid \` insert_line \` parameter: ${ insertLine } . It should be within the range of lines of the file: [0, ${ lines . length } ] ` ,
)
}
const after = [
. . . lines . slice ( 0 , insertLine ) ,
. . . value . split ( '\n' ) ,
. . . lines . slice ( insertLine ) ,
] . join ( '\n' )
2026-07-29 15:21:56 +08:00
const expected : FsWriteIntent = intent === undefined
? { kind : 'replaceIfVersion' , version : info.version }
: { kind : 'replaceIfVersion' , version : intent.version }
let outcome
try {
outcome = await ctx . fs . writeText ( target , after , expected , exec . signal , sandboxPolicy )
} catch ( error : unknown ) {
throw policy . mapError ( error , sandboxPolicy )
}
2026-08-09 15:22:50 +08:00
ctx . emit ( 'fs/observed' , target , { kind : 'present' , version : outcome.version } , exec )
2026-07-29 14:12:27 +08:00
return ` The file ${ target . displayPath } has been edited successfully. `
}
interface ResolvedConfig {
maxOutputChars : number
description : string
2026-07-29 15:21:56 +08:00
}
function presentEditorCall ( args : {
command : 'view' | 'create' | 'str_replace' | 'insert'
path : string
file_text? : string
insert_line? : number
new_str? : string
old_str? : string
} ) : ToolCallView {
switch ( args . command ) {
case 'view' :
return {
card : 'generic' ,
title : ` view ${ args . path } ` ,
kind : 'read' ,
locations : [ { path : args.path } ] ,
}
case 'create' :
return {
card : 'diff' ,
title : ` create ${ args . path } ` ,
diffs : [ { path : args.path , oldText : null , newText : args.file_text ? ? '' } ] ,
locations : [ { path : args.path } ] ,
}
case 'str_replace' :
return {
card : 'diff' ,
title : ` str_replace ${ args . path } ` ,
diffs : [ {
path : args.path ,
oldText : args.old_str ? ? null ,
newText : args.new_str ? ? '' ,
} ] ,
locations : [ { path : args.path } ] ,
}
case 'insert' :
return {
card : 'generic' ,
title : ` insert ${ args . path } ` ,
kind : 'edit' ,
locations : [ {
path : args.path ,
. . . args . insert_line === undefined ? { } : { line : Math.max ( 1 , args . insert_line + 1 ) } ,
} ] ,
}
}
2026-07-29 14:12:27 +08:00
}
/** Register the model-facing `str_replace_editor` tool. */
function registerStrReplaceEditor ( ctx : Context , config : ResolvedConfig ) : void {
2026-07-29 15:21:56 +08:00
const policy = new MutationPolicy ( ctx )
2026-07-29 14:12:27 +08:00
ctx . tools . register ( defineTool ( {
name : 'str_replace_editor' ,
description : config.description ,
parameters : {
command : {
type : 'string' ,
required : true ,
enum : [ 'view' , 'create' , 'str_replace' , 'insert' ] ,
description : 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.' ,
} ,
path : {
type : 'string' ,
required : true ,
description : 'Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.' ,
} ,
file_text : {
type : 'string' ,
description : 'Required parameter of `create` command, with the content of the file to be created.' ,
} ,
insert_line : {
type : 'integer' ,
description : 'Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.' ,
} ,
new_str : {
type : 'string' ,
description : 'Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.' ,
} ,
old_str : {
type : 'string' ,
description : 'Required parameter of `str_replace` command containing the string in `path` to replace.' ,
} ,
view_range : {
type : 'array' ,
items : { type : 'integer' } ,
description : 'Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.' ,
} ,
} ,
output : {
schema : { type : 'string' } ,
render : ( _args , value ) = > [ { type : 'text' , text : value } ] ,
} ,
async execute ( args , exec ) {
switch ( args . command ) {
case 'view' :
2026-07-29 21:37:12 +08:00
return viewPath ( ctx , args . path , args . view_range , config . maxOutputChars , exec )
2026-07-29 14:12:27 +08:00
case 'create' :
2026-07-29 21:37:12 +08:00
return createFile ( ctx , policy , args . path , args . file_text , exec )
2026-07-29 14:12:27 +08:00
case 'str_replace' :
2026-07-29 15:21:56 +08:00
return replaceInFile (
ctx ,
policy ,
args . path ,
args . old_str ,
args . new_str ,
exec ,
)
2026-07-29 14:12:27 +08:00
case 'insert' :
2026-07-29 15:21:56 +08:00
return insertInFile (
ctx ,
policy ,
args . path ,
args . insert_line ,
args . new_str ,
exec ,
)
2026-07-29 14:12:27 +08:00
}
} ,
2026-07-29 15:21:56 +08:00
presentCall : presentEditorCall ,
2026-07-29 14:12:27 +08:00
} ) )
}
export const name = 'tool-str-replace-editor'
export const inject = [ 'tools' , 'fs' ]
/** Configuration for the string-replacement editor tool. */
export interface Config {
/** Maximum returned view characters before clipping (default 16000). */
maxOutputChars? : number
/** Model-facing tool description. */
description? : string
}
/** Runtime configuration schema for the string-replacement editor tool. */
export const Config : z < Config > = z . object ( {
maxOutputChars : z.number ( ) . default ( 16 _000 ) ,
description : z.string ( ) . default ( DEFAULT_DESCRIPTION ) ,
} )
/** Register one `str_replace_editor` tool over `ctx.fs`. */
export function apply ( ctx : Context , config : Config ) : void {
const resolved : ResolvedConfig = {
maxOutputChars : config.maxOutputChars ? ? 16 _000 ,
description : config.description ? ? DEFAULT_DESCRIPTION ,
}
if ( ! Number . isSafeInteger ( resolved . maxOutputChars ) || resolved . maxOutputChars <= 0 ) {
throw new Error ( 'tool-str-replace-editor: maxOutputChars must be a positive safe integer' )
}
if ( resolved . description . trim ( ) . length === 0 ) {
throw new Error ( 'tool-str-replace-editor: description must be non-empty' )
}
registerStrReplaceEditor ( ctx , resolved )
}