index.ts 19 KB

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