write.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. /**
  2. * Model-facing full-file write. It obtains an optional intent from the single policy slot, calls
  3. * `ctx.fs.writeText` without a stat, then records the resulting version; no policy means an
  4. * unconditional atomic create-or-overwrite.
  5. * @module @deepseek-ai/dsh-tool-fs/src/write
  6. */
  7. import type { Context } from 'cordis'
  8. import { defineTool } from '@deepseek-ai/dsh-tools'
  9. import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
  10. import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
  11. import type {} from '@deepseek-ai/dsh-fs'
  12. import type {} from '@deepseek-ai/dsh-system-prompt'
  13. import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
  14. import { sessionResolveOptions } from './session-cwd.ts'
  15. import type { FsSandboxSurface } from './sandbox.ts'
  16. /**
  17. * Validate value constraints the schema DSL can't express: only a non-blank
  18. * `file_path` — an empty `content` is legitimate (it writes an empty file).
  19. * @param args - the schema-validated raw tool arguments.
  20. * @returns the camelCased input; `content` passes through untouched.
  21. */
  22. export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } {
  23. if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
  24. return { filePath: args.file_path, content: args.content }
  25. }
  26. /**
  27. * Format a write outcome as one model-facing text block body.
  28. * @param displayPath - the backend-resolved path rendered in the envelope's `<path>` element.
  29. * @param outcome - the write outcome; its `operation` selects the Created/Updated wording.
  30. * @returns the model-facing confirmation envelope (no file content is echoed back).
  31. */
  32. export function formatWriteOutput(displayPath: string, outcome: Pick<FsWriteOutcome, 'operation'>): string {
  33. const verb = outcome.operation === 'create' ? 'Created' : 'Updated'
  34. return `<path>${displayPath}</path>
  35. <type>file</type>
  36. <content>
  37. ${verb} file
  38. </content>`
  39. }
  40. /**
  41. * The `write` tool's validated argument shape: the base parameters plus the
  42. * two escalation fields, advertised only under a confining `ctx.fs` (absent
  43. * from the schema otherwise, so the validator rejects them before `execute`).
  44. */
  45. interface WriteToolArgs {
  46. file_path: string
  47. content: string
  48. sandbox_permissions?: string
  49. justification?: string
  50. }
  51. /**
  52. * Register the `write` tool and its system-prompt guidance.
  53. * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
  54. * @param sandbox - the shared sandbox-escalation surface (advertisement, mode stamping, denial mapping).
  55. */
  56. export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
  57. ctx.systemPrompt.section({
  58. name: 'tool:write',
  59. order: 101,
  60. 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.',
  61. })
  62. ctx.tools.register(defineTool({
  63. name: 'write',
  64. description: 'Create or fully replace a UTF-8 text file.',
  65. parameters: {
  66. file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' },
  67. content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
  68. ...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},
  69. },
  70. output: {
  71. schema: {
  72. type: 'object',
  73. additionalProperties: false,
  74. properties: {
  75. path: { type: 'string', required: true },
  76. operation: { type: 'string', required: true, enum: ['create', 'update'] },
  77. before: {
  78. required: true,
  79. oneOf: [
  80. { type: 'string' },
  81. { type: 'null' },
  82. ],
  83. },
  84. after: { type: 'string', required: true },
  85. },
  86. },
  87. render: (_args, value) => [{ type: 'text', text: formatWriteOutput(value.path, value) }],
  88. presentationMeta: (args, value) => ({
  89. diffs: value.before === null
  90. ? []
  91. : computeHunkDiffs(args.file_path, value.before, value.after)
  92. .map(({ path, oldText, newText }) => ({ path, oldText, newText })),
  93. }),
  94. },
  95. async execute(args: WriteToolArgs, exec) {
  96. const input = parseWriteArgs(args)
  97. // Resolve the per-call sandbox policy (approved mode > session override
  98. // > backend default, plus the session cwd root) BEFORE anything executes;
  99. // an escalating call throws its distinct text on any non-grant.
  100. const sandboxPolicy = await sandbox.resolvePolicy('write', args, exec)
  101. const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))
  102. // Single-slot decision: the policy plugin produces createIfAbsent/
  103. // replaceIfVersion; the bare default is undefined (unconditional). No stat.
  104. const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
  105. let outcome: FsWriteOutcome
  106. try {
  107. outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy)
  108. } catch (error: unknown) {
  109. // A sandbox denial becomes the shared [sandbox: …] marker (the model
  110. // recognizes it from bash); any other error passes through.
  111. throw sandbox.mapError(error, sandboxPolicy)
  112. }
  113. // Record the observed version (a no-op when no policy plugin listens).
  114. ctx.emit('fs/observed', target, outcome.version, exec)
  115. return {
  116. path: target.displayPath,
  117. operation: outcome.operation,
  118. before: outcome.before,
  119. after: outcome.after,
  120. }
  121. },
  122. // Pure display: a diff card. A call-time presenter has no access to prior
  123. // file content, so `oldText: null` also represents an overwrite here.
  124. presentCall(args): DiffCallView {
  125. return {
  126. card: 'diff',
  127. title: `Write ${args.file_path}`,
  128. diffs: [{ path: args.file_path, oldText: null, newText: args.content }],
  129. locations: [{ path: args.file_path }],
  130. }
  131. },
  132. // Result-time display repeats the diff because completed views replace the
  133. // pending view. Overwrites use applied metadata; creates and identical
  134. // overwrites use the replay-safe args fallback.
  135. presentResult(args, result: ToolResult): DiffResultView | undefined {
  136. if (result.isError) return undefined
  137. const diffs = diffsFromMeta(result.meta)
  138. ?? [{ path: args.file_path, oldText: null, newText: args.content }]
  139. return { card: 'diff', title: `Write ${args.file_path}`, diffs }
  140. },
  141. }))
  142. }