index.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. /**
  2. * Model-facing `str_replace_editor` over the Harness filesystem seam.
  3. * @module @deepseek-ai/dsh-tool-str-replace-editor
  4. */
  5. import { isAbsolute } from 'node:path'
  6. import type { Context } from '@deepseek-ai/cordis'
  7. import z from '@deepseek-ai/schemastery'
  8. import { FsError } from '@deepseek-ai/dsh-fs'
  9. import type { FsInfo, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
  10. import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
  11. import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
  12. import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
  13. import { defineTool } from '@deepseek-ai/dsh-tools'
  14. import type { ToolCallView, ToolRunContext } from '@deepseek-ai/dsh-tools'
  15. 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>'
  16. const DEFAULT_DESCRIPTION = `
  17. Custom editing tool for viewing, creating and editing files
  18. * State is persistent across command calls and discussions with the user
  19. * 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
  20. * The \`create\` command cannot be used if the specified \`path\` already exists as a file
  21. * If a \`command\` generates a long output, it will be truncated and marked with \`<response clipped>\`
  22. * A null placeholder for a parameter unused by the selected command is treated as omitted. Required parameters still need values; omit \`str_replace.new_str\` rather than setting it to null when deleting a match
  23. Notes for using the \`str_replace\` command:
  24. * The \`old_str\` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
  25. * 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
  26. * The \`new_str\` parameter should contain the edited lines that should replace the \`old_str\`
  27. `.trim()
  28. function maybeTruncate(content: string, maxOutputChars: number): string {
  29. return content.length <= maxOutputChars
  30. ? content
  31. : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
  32. }
  33. function codepointCompare(left: string, right: string): number {
  34. return left < right ? -1 : left > right ? 1 : 0
  35. }
  36. function matchOffsets(content: string, search: string): number[] {
  37. const offsets: number[] = []
  38. let offset = 0
  39. while (true) {
  40. const match = content.indexOf(search, offset)
  41. if (match < 0) return offsets
  42. offsets.push(match)
  43. offset = match + search.length
  44. }
  45. }
  46. function lineNumbersAt(content: string, offsets: readonly number[]): number[] {
  47. let line = 1
  48. let cursor = 0
  49. return offsets.map((offset) => {
  50. while (cursor < offset) {
  51. if (content[cursor] === '\n') line += 1
  52. cursor += 1
  53. }
  54. return line
  55. })
  56. }
  57. class MutationPolicy {
  58. private readonly policy: SandboxPolicyService | undefined
  59. constructor(ctx: Context) {
  60. this.policy = ctx.fs.sandboxMode === undefined ? undefined : ctx.get('sandboxPolicy')
  61. if (ctx.fs.sandboxMode !== undefined && this.policy === undefined) {
  62. throw new Error('tool-str-replace-editor: the mounted filesystem confines but ctx.sandboxPolicy is missing')
  63. }
  64. }
  65. resolve(exec: ToolRunContext): SandboxExecutionPolicy | undefined {
  66. return this.policy?.resolve({
  67. ...exec.agent === undefined ? {} : { session: exec.agent.session },
  68. })
  69. }
  70. mapError(error: unknown, policy: SandboxExecutionPolicy | undefined): unknown {
  71. if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error
  72. const mode = (policy as SandboxExecutionPolicy).mode
  73. return new FsError(sandboxDenialMarker(mode), 'FS_SANDBOX_DENIED', { cause: error })
  74. }
  75. }
  76. async function resolveTarget(
  77. ctx: Context,
  78. path: string,
  79. signal: AbortSignal,
  80. ): Promise<FsTarget> {
  81. if (path.trim().length === 0) throw new Error('path must be a non-empty string')
  82. if (!isAbsolute(path)) {
  83. throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`)
  84. }
  85. return ctx.fs.resolve(path, { signal })
  86. }
  87. async function statExisting(
  88. ctx: Context,
  89. target: FsTarget,
  90. command: 'view' | 'str_replace' | 'insert',
  91. exec: ToolRunContext,
  92. ): Promise<FsInfo> {
  93. const info = await ctx.fs.stat(target, exec.signal)
  94. if (info === undefined) {
  95. ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
  96. throw new FsError(
  97. `The path ${target.displayPath} does not exist. Please provide a valid path.`,
  98. 'FS_NOT_FOUND',
  99. )
  100. }
  101. if (info.type === 'directory' && command !== 'view') {
  102. throw new FsError(
  103. `The path ${target.displayPath} is a directory and only the \`view\` command can be used on directories`,
  104. 'FS_NOT_REGULAR_FILE',
  105. )
  106. }
  107. return info
  108. }
  109. function requiredForCommand(
  110. value: string | undefined,
  111. parameter: string,
  112. command: string,
  113. allowEmpty = true,
  114. ): string {
  115. if (value === undefined) throw new Error(`Parameter \`${parameter}\` is required for command: ${command}`)
  116. if (!allowEmpty && value.length === 0) {
  117. throw new Error(`Parameter \`${parameter}\` is empty for command: ${command}`)
  118. }
  119. return value
  120. }
  121. function formatFileView(
  122. path: string,
  123. content: string,
  124. maxOutputChars: number,
  125. viewRange?: number[],
  126. ): string {
  127. const allLines = content.split('\n')
  128. let lines = allLines
  129. let initialLine = 1
  130. let finalLine: number | undefined
  131. let prompt = `Here's the content of ${path} with line numbers (which has a total of ${allLines.length} lines)`
  132. if (viewRange !== undefined) {
  133. const [requestedInitialLine, requestedFinalLine] = viewRange
  134. if (
  135. viewRange.length !== 2
  136. || requestedInitialLine === undefined
  137. || requestedFinalLine === undefined
  138. || !viewRange.every(Number.isInteger)
  139. ) {
  140. throw new Error('Invalid `view_range`. It should be a list of two integers.')
  141. }
  142. initialLine = requestedInitialLine
  143. finalLine = requestedFinalLine
  144. if (initialLine < 1 || initialLine > allLines.length) {
  145. throw new Error(
  146. `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its first element \`${initialLine}\` should be within the range of lines of the file: [1, ${allLines.length}]`,
  147. )
  148. }
  149. if (finalLine > allLines.length) {
  150. throw new Error(
  151. `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be smaller than the number of lines in the file: \`${allLines.length}\``,
  152. )
  153. }
  154. if (finalLine !== -1 && finalLine < initialLine) {
  155. throw new Error(
  156. `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be larger or equal than its first \`${initialLine}\``,
  157. )
  158. }
  159. lines = finalLine === -1
  160. ? allLines.slice(initialLine - 1)
  161. : allLines.slice(initialLine - 1, finalLine)
  162. prompt += ` with view_range=[${initialLine}, ${finalLine}]`
  163. }
  164. const numbered = lines
  165. .map((line, index) => `${String(initialLine + index).padStart(6, ' ')} ${line}`)
  166. .join('\n')
  167. return maybeTruncate(`${prompt}:\n${numbered}\n`, maxOutputChars)
  168. }
  169. async function listDirectory(
  170. ctx: Context,
  171. target: FsTarget,
  172. maxOutputChars: number,
  173. exec: ToolRunContext,
  174. ): Promise<string> {
  175. async function visit(dir: FsTarget, depth: number): Promise<string[]> {
  176. const entries = await ctx.fs.listDir(dir, exec.signal)
  177. const rows: string[] = []
  178. for (const entry of entries.filter(candidate =>
  179. !candidate.name.startsWith('.')
  180. && candidate.name !== 'node_modules'
  181. && candidate.name !== '__pycache__')) {
  182. const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?'
  183. rows.push(`${type}\t${entry.target.displayPath}`)
  184. if (entry.type === 'directory' && depth < 2) {
  185. rows.push(...await visit(entry.target, depth + 1))
  186. }
  187. }
  188. return rows
  189. }
  190. const rows = [`d\t${target.displayPath}`, ...await visit(target, 1)]
  191. rows.sort((left, right) => {
  192. const leftPath = left.slice(left.indexOf('\t') + 1)
  193. const rightPath = right.slice(right.indexOf('\t') + 1)
  194. return codepointCompare(leftPath, rightPath)
  195. })
  196. const listing = maybeTruncate(rows.join('\n') + '\n', maxOutputChars)
  197. 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`
  198. }
  199. async function viewPath(
  200. ctx: Context,
  201. path: string,
  202. viewRange: number[] | undefined,
  203. maxOutputChars: number,
  204. exec: ToolRunContext,
  205. ): Promise<string> {
  206. const target = await resolveTarget(ctx, path, exec.signal)
  207. const info = await statExisting(ctx, target, 'view', exec)
  208. if (info.type === 'directory') {
  209. if (viewRange !== undefined) {
  210. throw new Error('The `view_range` parameter is not allowed when `path` points to a directory.')
  211. }
  212. return listDirectory(ctx, target, maxOutputChars, exec)
  213. }
  214. if (info.type !== 'file') {
  215. throw new FsError(`cannot view "${target.displayPath}": not a regular file or directory`, 'FS_NOT_REGULAR_FILE')
  216. }
  217. const content = await ctx.fs.readText(target, exec.signal)
  218. ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec)
  219. return formatFileView(target.displayPath, content, maxOutputChars, viewRange)
  220. }
  221. async function createFile(
  222. ctx: Context,
  223. policy: MutationPolicy,
  224. path: string,
  225. fileText: string | undefined,
  226. exec: ToolRunContext,
  227. ): Promise<string> {
  228. const content = requiredForCommand(fileText, 'file_text', 'create')
  229. const sandboxPolicy = policy.resolve(exec)
  230. const target = await resolveTarget(ctx, path, exec.signal)
  231. if (await ctx.fs.stat(target, exec.signal) !== undefined) {
  232. throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`)
  233. }
  234. const intent = await ctx.waterfall(
  235. 'fs/write-intent',
  236. target,
  237. exec,
  238. () => ({ kind: 'createIfAbsent' } as const),
  239. )
  240. let outcome
  241. try {
  242. outcome = await ctx.fs.writeText(
  243. target,
  244. content,
  245. intent,
  246. exec.signal,
  247. sandboxPolicy,
  248. )
  249. } catch (error: unknown) {
  250. throw policy.mapError(error, sandboxPolicy)
  251. }
  252. ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
  253. return `New file created successfully at: ${target.displayPath}`
  254. }
  255. async function replaceInFile(
  256. ctx: Context,
  257. policy: MutationPolicy,
  258. path: string,
  259. oldStr: string | undefined,
  260. newStr: string | null | undefined,
  261. exec: ToolRunContext,
  262. ): Promise<string> {
  263. if (newStr === null) {
  264. throw new Error('Parameter `new_str` must be omitted or contain a string for command: str_replace')
  265. }
  266. const sandboxPolicy = policy.resolve(exec)
  267. const target = await resolveTarget(ctx, path, exec.signal)
  268. const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
  269. const oldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false)
  270. const newValue = newStr ?? ''
  271. const info = await statExisting(ctx, target, 'str_replace', exec)
  272. if (info.type !== 'file') {
  273. throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
  274. }
  275. const before = await ctx.fs.readText(target, exec.signal)
  276. const offsets = matchOffsets(before, oldValue)
  277. const offset = offsets[0]
  278. if (offset === undefined) {
  279. throw new FsError(
  280. `No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`,
  281. 'FS_EDIT_NOT_FOUND',
  282. )
  283. }
  284. if (offsets.length > 1) {
  285. const lines = lineNumbersAt(before, offsets)
  286. throw new FsError(
  287. `No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`,
  288. 'FS_AMBIGUOUS_EDIT',
  289. )
  290. }
  291. let outcome
  292. try {
  293. outcome = await ctx.fs.writeText(
  294. target,
  295. before.slice(0, offset) + newValue + before.slice(offset + oldValue.length),
  296. intent === undefined
  297. ? { kind: 'replaceIfVersion', version: info.version }
  298. : { kind: 'replaceIfVersion', version: intent.version },
  299. exec.signal,
  300. sandboxPolicy,
  301. )
  302. } catch (error: unknown) {
  303. throw policy.mapError(error, sandboxPolicy)
  304. }
  305. ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
  306. return `The file ${target.displayPath} has been edited successfully.`
  307. }
  308. async function insertInFile(
  309. ctx: Context,
  310. policy: MutationPolicy,
  311. path: string,
  312. insertLine: number | undefined,
  313. newStr: string | undefined,
  314. exec: ToolRunContext,
  315. ): Promise<string> {
  316. if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert')
  317. const value = requiredForCommand(newStr, 'new_str', 'insert')
  318. const sandboxPolicy = policy.resolve(exec)
  319. const target = await resolveTarget(ctx, path, exec.signal)
  320. const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
  321. const info = await statExisting(ctx, target, 'insert', exec)
  322. if (info.type !== 'file') {
  323. throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
  324. }
  325. const before = await ctx.fs.readText(target, exec.signal)
  326. const lines = before.split('\n')
  327. if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) {
  328. throw new Error(
  329. `Invalid \`insert_line\` parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`,
  330. )
  331. }
  332. const after = [
  333. ...lines.slice(0, insertLine),
  334. ...value.split('\n'),
  335. ...lines.slice(insertLine),
  336. ].join('\n')
  337. const expected: FsWriteIntent = intent === undefined
  338. ? { kind: 'replaceIfVersion', version: info.version }
  339. : { kind: 'replaceIfVersion', version: intent.version }
  340. let outcome
  341. try {
  342. outcome = await ctx.fs.writeText(target, after, expected, exec.signal, sandboxPolicy)
  343. } catch (error: unknown) {
  344. throw policy.mapError(error, sandboxPolicy)
  345. }
  346. ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
  347. return `The file ${target.displayPath} has been edited successfully.`
  348. }
  349. interface ResolvedConfig {
  350. maxOutputChars: number
  351. description: string
  352. }
  353. function presentEditorCall(args: {
  354. command: 'view' | 'create' | 'str_replace' | 'insert'
  355. path: string
  356. file_text?: string | null
  357. insert_line?: number | null
  358. new_str?: string | null
  359. old_str?: string | null
  360. }): ToolCallView {
  361. switch (args.command) {
  362. case 'view':
  363. return {
  364. card: 'generic',
  365. title: `view ${args.path}`,
  366. kind: 'read',
  367. locations: [{ path: args.path }],
  368. }
  369. case 'create':
  370. return {
  371. card: 'diff',
  372. title: `create ${args.path}`,
  373. diffs: [{ path: args.path, oldText: null, newText: args.file_text ?? '' }],
  374. locations: [{ path: args.path }],
  375. }
  376. case 'str_replace':
  377. return {
  378. card: 'diff',
  379. title: `str_replace ${args.path}`,
  380. diffs: [{
  381. path: args.path,
  382. oldText: args.old_str ?? null,
  383. newText: args.new_str ?? '',
  384. }],
  385. locations: [{ path: args.path }],
  386. }
  387. case 'insert':
  388. return {
  389. card: 'generic',
  390. title: `insert ${args.path}`,
  391. kind: 'edit',
  392. locations: [{
  393. path: args.path,
  394. ...args.insert_line === undefined || args.insert_line === null
  395. ? {}
  396. : { line: Math.max(1, args.insert_line + 1) },
  397. }],
  398. }
  399. }
  400. }
  401. /** Register the model-facing `str_replace_editor` tool. */
  402. function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void {
  403. const policy = new MutationPolicy(ctx)
  404. ctx.tools.register(defineTool({
  405. name: 'str_replace_editor',
  406. description: config.description,
  407. parameters: {
  408. command: {
  409. type: 'string',
  410. required: true,
  411. enum: ['view', 'create', 'str_replace', 'insert'],
  412. description: 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.',
  413. },
  414. path: {
  415. type: 'string',
  416. required: true,
  417. description: 'Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.',
  418. },
  419. file_text: {
  420. oneOf: [{ type: 'string' }, { type: 'null' }],
  421. description: 'Required string parameter of `create` command, with the content of the file to be created. A null placeholder is treated as omitted by commands that do not use this parameter.',
  422. },
  423. insert_line: {
  424. oneOf: [{ type: 'integer' }, { type: 'null' }],
  425. description: 'Required integer parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`. A null placeholder is treated as omitted by commands that do not use this parameter.',
  426. },
  427. new_str: {
  428. oneOf: [{ type: 'string' }, { type: 'null' }],
  429. description: 'Optional string parameter of `str_replace` command containing the new string (if omitted, no string will be added). Required string parameter of `insert` command containing the string to insert. A null placeholder is accepted only by commands that do not use this parameter.',
  430. },
  431. old_str: {
  432. oneOf: [{ type: 'string' }, { type: 'null' }],
  433. description: 'Required string parameter of `str_replace` command containing the string in `path` to replace. A null placeholder is treated as omitted by commands that do not use this parameter.',
  434. },
  435. view_range: {
  436. oneOf: [
  437. { type: 'array', items: { type: 'integer' } },
  438. { type: 'null' },
  439. ],
  440. description: 'Optional parameter of `view` command when `path` points to a file. If omitted or null, 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.',
  441. },
  442. },
  443. output: {
  444. schema: { type: 'string' },
  445. render: (_args, value) => [{ type: 'text', text: value }],
  446. },
  447. async execute(args, exec) {
  448. switch (args.command) {
  449. case 'view':
  450. return viewPath(ctx, args.path, args.view_range ?? undefined, config.maxOutputChars, exec)
  451. case 'create':
  452. return createFile(ctx, policy, args.path, args.file_text ?? undefined, exec)
  453. case 'str_replace':
  454. return replaceInFile(
  455. ctx,
  456. policy,
  457. args.path,
  458. args.old_str ?? undefined,
  459. args.new_str,
  460. exec,
  461. )
  462. case 'insert':
  463. return insertInFile(
  464. ctx,
  465. policy,
  466. args.path,
  467. args.insert_line ?? undefined,
  468. args.new_str ?? undefined,
  469. exec,
  470. )
  471. }
  472. },
  473. presentCall: presentEditorCall,
  474. }))
  475. }
  476. export const name = 'tool-str-replace-editor'
  477. export const inject = ['tools', 'fs']
  478. /** Configuration for the string-replacement editor tool. */
  479. export interface Config {
  480. /** Maximum returned view characters before clipping (default 16000). */
  481. maxOutputChars?: number
  482. /** Model-facing tool description. */
  483. description?: string
  484. }
  485. /** Runtime configuration schema for the string-replacement editor tool. */
  486. export const Config: z<Config> = z.object({
  487. maxOutputChars: z.number().default(16_000),
  488. description: z.string().default(DEFAULT_DESCRIPTION),
  489. })
  490. /** Register one `str_replace_editor` tool over `ctx.fs`. */
  491. export function apply(ctx: Context, config: Config): void {
  492. const resolved: ResolvedConfig = {
  493. maxOutputChars: config.maxOutputChars ?? 16_000,
  494. description: config.description ?? DEFAULT_DESCRIPTION,
  495. }
  496. if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
  497. throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer')
  498. }
  499. if (resolved.description.trim().length === 0) {
  500. throw new Error('tool-str-replace-editor: description must be non-empty')
  501. }
  502. registerStrReplaceEditor(ctx, resolved)
  503. }