index.ts 19 KB

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