@namzu/sdk 1.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/registry/tool/execute.d.ts.map +1 -1
  3. package/dist/registry/tool/execute.js +40 -10
  4. package/dist/registry/tool/execute.js.map +1 -1
  5. package/dist/registry/tool/execute.test.js +93 -7
  6. package/dist/registry/tool/execute.test.js.map +1 -1
  7. package/dist/registry/toolset/catalog.d.ts.map +1 -1
  8. package/dist/registry/toolset/catalog.js +17 -4
  9. package/dist/registry/toolset/catalog.js.map +1 -1
  10. package/dist/registry/toolset/catalog.test.js +101 -0
  11. package/dist/registry/toolset/catalog.test.js.map +1 -1
  12. package/dist/runtime/query/__tests__/capability-negotiation.test.js +79 -0
  13. package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -1
  14. package/dist/runtime/query/__tests__/deferred-tools.test.js +100 -0
  15. package/dist/runtime/query/__tests__/deferred-tools.test.js.map +1 -1
  16. package/dist/runtime/query/__tests__/long-document-flow.test.js +12 -12
  17. package/dist/runtime/query/__tests__/long-document-flow.test.js.map +1 -1
  18. package/dist/runtime/query/__tests__/stream-recovery.test.js +1 -1
  19. package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
  20. package/dist/runtime/query/executor.js +2 -2
  21. package/dist/runtime/query/executor.js.map +1 -1
  22. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  23. package/dist/runtime/query/iteration/index.js +10 -0
  24. package/dist/runtime/query/iteration/index.js.map +1 -1
  25. package/dist/tools/builtins/__tests__/edit.test.js +206 -54
  26. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  27. package/dist/tools/builtins/__tests__/payload-budget.test.js +30 -16
  28. package/dist/tools/builtins/__tests__/payload-budget.test.js.map +1 -1
  29. package/dist/tools/builtins/__tests__/write-file.test.js +37 -13
  30. package/dist/tools/builtins/__tests__/write-file.test.js.map +1 -1
  31. package/dist/tools/builtins/atomic-write-file.d.ts +13 -0
  32. package/dist/tools/builtins/atomic-write-file.d.ts.map +1 -0
  33. package/dist/tools/builtins/atomic-write-file.js +46 -0
  34. package/dist/tools/builtins/atomic-write-file.js.map +1 -0
  35. package/dist/tools/builtins/bash.js +2 -2
  36. package/dist/tools/builtins/bash.js.map +1 -1
  37. package/dist/tools/builtins/edit.d.ts +2 -5
  38. package/dist/tools/builtins/edit.d.ts.map +1 -1
  39. package/dist/tools/builtins/edit.js +107 -125
  40. package/dist/tools/builtins/edit.js.map +1 -1
  41. package/dist/tools/builtins/file-mutation-lock.d.ts +7 -0
  42. package/dist/tools/builtins/file-mutation-lock.d.ts.map +1 -0
  43. package/dist/tools/builtins/file-mutation-lock.js +25 -0
  44. package/dist/tools/builtins/file-mutation-lock.js.map +1 -0
  45. package/dist/tools/builtins/index.js +2 -2
  46. package/dist/tools/builtins/index.js.map +1 -1
  47. package/dist/tools/builtins/write-file.d.ts +1 -2
  48. package/dist/tools/builtins/write-file.d.ts.map +1 -1
  49. package/dist/tools/builtins/write-file.js +66 -40
  50. package/dist/tools/builtins/write-file.js.map +1 -1
  51. package/dist/tools/defineTool.d.ts +2 -0
  52. package/dist/tools/defineTool.d.ts.map +1 -1
  53. package/dist/tools/defineTool.js +2 -0
  54. package/dist/tools/defineTool.js.map +1 -1
  55. package/dist/types/provider/chat.d.ts +9 -0
  56. package/dist/types/provider/chat.d.ts.map +1 -1
  57. package/dist/types/sandbox/index.d.ts +4 -0
  58. package/dist/types/sandbox/index.d.ts.map +1 -1
  59. package/dist/types/sandbox/index.js.map +1 -1
  60. package/dist/types/tool/index.d.ts +14 -0
  61. package/dist/types/tool/index.d.ts.map +1 -1
  62. package/package.json +2 -1
  63. package/src/registry/tool/execute.test.ts +107 -7
  64. package/src/registry/tool/execute.ts +45 -10
  65. package/src/registry/toolset/catalog.test.ts +118 -0
  66. package/src/registry/toolset/catalog.ts +26 -4
  67. package/src/runtime/query/__tests__/capability-negotiation.test.ts +88 -0
  68. package/src/runtime/query/__tests__/deferred-tools.test.ts +111 -0
  69. package/src/runtime/query/__tests__/long-document-flow.test.ts +12 -12
  70. package/src/runtime/query/__tests__/stream-recovery.test.ts +1 -1
  71. package/src/runtime/query/executor.ts +2 -2
  72. package/src/runtime/query/iteration/index.ts +15 -0
  73. package/src/tools/builtins/__tests__/edit.test.ts +248 -64
  74. package/src/tools/builtins/__tests__/payload-budget.test.ts +40 -20
  75. package/src/tools/builtins/__tests__/write-file.test.ts +52 -13
  76. package/src/tools/builtins/atomic-write-file.ts +58 -0
  77. package/src/tools/builtins/bash.ts +2 -2
  78. package/src/tools/builtins/edit.ts +117 -163
  79. package/src/tools/builtins/file-mutation-lock.ts +27 -0
  80. package/src/tools/builtins/index.ts +2 -2
  81. package/src/tools/builtins/write-file.ts +68 -44
  82. package/src/tools/defineTool.ts +4 -0
  83. package/src/types/provider/chat.ts +14 -1
  84. package/src/types/sandbox/index.ts +4 -0
  85. package/src/types/tool/index.ts +14 -0
@@ -1,78 +1,77 @@
1
- import { readFile, writeFile } from 'node:fs/promises'
1
+ import { readFile } from 'node:fs/promises'
2
2
  import { resolve } from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import { defineTool } from '../defineTool.js'
5
+ import { atomicWriteFile } from './atomic-write-file.js'
6
+ import { withFileMutationLock } from './file-mutation-lock.js'
5
7
 
6
8
  const inputSchema = z
7
9
  .object({
8
- path: z.string().describe('Path to the file to edit'),
9
- old_string: z
10
+ path: z
10
11
  .string()
11
- .optional()
12
- .describe('The exact string to find and replace. Must be unique in the file.'),
13
- oldStr: z
12
+ .refine((value) => value.trim().length > 0, 'Path must not be empty.')
13
+ .describe('Path to the file to edit. Must not be empty.'),
14
+ old_string: z
14
15
  .string()
15
- .optional()
16
+ .min(1)
16
17
  .describe(
17
- 'Alias for old_string. Used by hosts that expose text replacement as oldStr/newStr.',
18
+ 'The exact unique text to replace, without read-tool line-number prefixes. Must not be empty.',
18
19
  ),
19
20
  new_string: z
20
21
  .string()
21
- .optional()
22
- .describe(
23
- 'The replacement string. Self-budget this payload under 12000 characters before calling.',
24
- ),
25
- newStr: z
26
- .string()
27
- .optional()
28
- .describe(
29
- 'Alias for new_string. Also used as inserted content when insertLine is provided. Self-budget this payload under 12000 characters before calling.',
30
- ),
31
- insertLine: z
32
- .union([z.number().int().min(0), z.literal('end')])
33
- .optional()
34
22
  .describe(
35
- 'Optional line insertion target. Pass a JSON integer to insert after that 1-indexed line, 0 to insert before the first line, or the exact string "end" to append. Headings, anchors, numeric strings, null, and empty strings are invalid.',
23
+ 'The exact replacement text. May be empty to delete old_string. Keep this payload under 12000 characters.',
36
24
  ),
37
25
  replace_all: z
38
26
  .boolean()
39
27
  .default(false)
40
- .describe('Replace all occurrences instead of just the first unique match'),
28
+ .describe('Replace every occurrence instead of requiring one unique match.'),
41
29
  })
42
- .refine((value) => typeof value.new_string === 'string' || typeof value.newStr === 'string', {
43
- message: 'Either new_string or newStr is required.',
44
- })
45
- .refine(
46
- (value) =>
47
- value.insertLine !== undefined ||
48
- typeof value.old_string === 'string' ||
49
- typeof value.oldStr === 'string',
50
- { message: 'Either old_string/oldStr or insertLine is required.' },
51
- )
30
+ .strict()
52
31
 
53
32
  type EditInput = z.infer<typeof inputSchema>
54
33
 
55
- type NormalizedEditInput =
56
- | {
57
- operation: 'replace'
58
- oldString: string
59
- newString: string
60
- replace_all: boolean
61
- }
62
- | {
63
- operation: 'insert'
64
- insertLine: number | 'end'
65
- newString: string
66
- replace_all: boolean
67
- }
34
+ const modelInputSchema: Record<string, unknown> = {
35
+ type: 'object',
36
+ properties: {
37
+ path: {
38
+ type: 'string',
39
+ description: 'Path to the file to edit. Must not be empty.',
40
+ },
41
+ old_string: {
42
+ type: 'string',
43
+ description:
44
+ 'Exact unique text from the file, without read-tool line-number prefixes. Must not be empty.',
45
+ },
46
+ new_string: {
47
+ type: 'string',
48
+ description:
49
+ 'Exact replacement text. May be empty to delete old_string. Keep under 12000 characters.',
50
+ },
51
+ replace_all: {
52
+ type: 'boolean',
53
+ description: 'Replace every occurrence instead of requiring one unique match.',
54
+ },
55
+ },
56
+ required: ['path', 'old_string', 'new_string'],
57
+ additionalProperties: false,
58
+ }
59
+
60
+ type ExactReplacement = Readonly<{
61
+ oldString: string
62
+ newString: string
63
+ replaceAll: boolean
64
+ }>
68
65
 
69
66
  export const EditTool = defineTool({
70
67
  name: 'edit',
71
68
  description:
72
- 'Makes targeted edits to a file using exact string find-and-replace or line insertion. THIS IS THE PREFERRED WAY TO MODIFY AN EXISTING FILE never reach for `write` to change a file that already exists, because `write` overwrites the whole body and discards earlier work on partial failure. `edit` keeps the rest of the file byte-for-byte intact and is recoverable: if a single edit fails (old_string/oldStr ambiguous, broader restructuring needed), follow up with another `edit` instead of re-emitting the entire file via `write`. The old_string/oldStr must be unique in the file unless replace_all is true. For insertions, pass insertLine plus new_string/newStr; use insertLine: "end" to extend a file at the end. Self-budget new_string/newStr under 12000 characters before emitting the tool call; use repeated bounded edits for long sections. Preserves file formatting and indentation.',
69
+ "Performs one targeted exact-string replacement in an existing file. Pass path + old_string + new_string; old_string must match exactly and be unique unless replace_all is true. Read the file immediately before editing, preserve whitespace and indentation, and never include the read tool's line-number prefix in old_string. To append or insert, replace a unique existing tail/marker with itself plus the new text; advance a deterministic marker such as {{CHUNK_001}} to {{CHUNK_002}} so replaying a completed edit cannot duplicate content. Prefer this tool over rewriting an existing file. Self-budget new_string under 12000 characters.",
73
70
  inputSchema,
71
+ modelInputSchema,
72
+ enforceModelInput: true,
74
73
  validationErrorHint:
75
- 'Accepted shapes: {"path":"file.md","insertLine":"end","new_string":"text"} or {"path":"file.md","old_string":"old","new_string":"new"}. Always include path; insertLine accepts only a non-negative JSON integer or the exact string "end".',
74
+ 'Required shape: {"path":"file.md","old_string":"exact unique text","new_string":"replacement text"}. Optional: "replace_all": true.',
76
75
  category: 'filesystem',
77
76
  permissions: ['file_write'],
78
77
  readOnly: false,
@@ -80,142 +79,120 @@ export const EditTool = defineTool({
80
79
  concurrencySafe: false,
81
80
 
82
81
  async execute(input: EditInput, context) {
83
- const normalized = normalizeEditInput(input)
84
- if (!normalized.success) {
85
- return { success: false, output: '', error: normalized.error }
86
- }
87
- if (
88
- normalized.operation.operation === 'replace' &&
89
- normalized.operation.oldString === normalized.operation.newString
90
- ) {
82
+ const parsed = inputSchema.safeParse(input)
83
+ if (!parsed.success) {
91
84
  return {
92
85
  success: false,
93
86
  output: '',
94
- error: 'old_string/oldStr and new_string/newStr are identical no change needed',
87
+ error: `Invalid edit input: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`,
95
88
  }
96
89
  }
90
+ const canonicalInput = parsed.data
97
91
 
98
- // Sandbox-aware: route through sandbox when available
99
- if (context.sandbox) {
100
- const buffer = await context.sandbox.readFile(input.path)
101
- const content = buffer.toString('utf-8')
102
-
103
- const result = applyEdit(content, normalized.operation)
104
- if (!result.success) {
105
- return { success: false, output: '', error: result.error }
106
- }
107
-
108
- await context.sandbox.writeFile(input.path, result.content)
92
+ const replacement: ExactReplacement = {
93
+ oldString: canonicalInput.old_string,
94
+ newString: canonicalInput.new_string,
95
+ replaceAll: canonicalInput.replace_all,
96
+ }
97
+ if (replacement.oldString === replacement.newString) {
109
98
  return {
110
- success: true,
111
- output: `Edited ${input.path}: ${result.replacements} replacement(s) [sandboxed]`,
112
- data: { path: input.path, replacements: result.replacements, sandboxed: true },
99
+ success: false,
100
+ output: '',
101
+ error: 'old_string and new_string are identical no change needed.',
113
102
  }
114
103
  }
115
104
 
116
- const filePath = resolve(context.workingDirectory, input.path)
117
- const content = await readFile(filePath, 'utf-8')
105
+ const filePath = resolve(context.workingDirectory, canonicalInput.path)
106
+ const lockKey = `${context.sandbox ? 'sandbox' : 'local'}:${filePath}`
107
+
108
+ return withFileMutationLock(lockKey, async () => {
109
+ if (context.sandbox) {
110
+ const buffer = await context.sandbox.readFile(canonicalInput.path)
111
+ const result = applyEdit(buffer.toString('utf-8'), replacement)
112
+ if (!result.success) return { success: false as const, output: '', error: result.error }
113
+
114
+ await context.sandbox.writeFile(canonicalInput.path, result.content)
115
+ return {
116
+ success: true as const,
117
+ output: `Edited ${canonicalInput.path}: ${result.replacements} replacement(s) [sandboxed]`,
118
+ data: {
119
+ path: canonicalInput.path,
120
+ replacements: result.replacements,
121
+ sandboxed: true,
122
+ },
123
+ }
124
+ }
118
125
 
119
- const result = applyEdit(content, normalized.operation)
120
- if (!result.success) {
121
- return { success: false, output: '', error: result.error }
122
- }
126
+ const content = await readFile(filePath, 'utf-8')
127
+ const result = applyEdit(content, replacement)
128
+ if (!result.success) return { success: false as const, output: '', error: result.error }
123
129
 
124
- await writeFile(filePath, result.content, 'utf-8')
125
- return {
126
- success: true,
127
- output: `Edited ${filePath}: ${result.replacements} replacement(s)`,
128
- data: { path: filePath, replacements: result.replacements },
129
- }
130
+ await atomicWriteFile(filePath, result.content)
131
+ return {
132
+ success: true as const,
133
+ output: `Edited ${filePath}: ${result.replacements} replacement(s)`,
134
+ data: { path: filePath, replacements: result.replacements },
135
+ }
136
+ })
130
137
  },
131
138
  })
132
139
 
133
- function normalizeEditInput(
134
- input: EditInput,
135
- ): { success: true; operation: NormalizedEditInput } | { success: false; error: string } {
136
- const newString = input.new_string ?? input.newStr
137
- if (typeof newString !== 'string') {
138
- return { success: false, error: 'Either new_string or newStr is required.' }
139
- }
140
-
141
- if (input.insertLine !== undefined) {
142
- const insertLine = normalizeInsertLine(input.insertLine)
143
- if (!insertLine.success) return insertLine
140
+ function normalizeLineEndings(content: string, input: ExactReplacement): ExactReplacement {
141
+ const withoutCrlf = content.replaceAll('\r\n', '')
142
+ const usesOnlyCrlf = content.includes('\r\n') && !withoutCrlf.includes('\n')
143
+ if (usesOnlyCrlf) {
144
144
  return {
145
- success: true,
146
- operation: {
147
- operation: 'insert',
148
- insertLine: insertLine.value,
149
- newString,
150
- replace_all: input.replace_all,
151
- },
145
+ ...input,
146
+ oldString: content.includes(input.oldString)
147
+ ? input.oldString
148
+ : input.oldString.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n'),
149
+ newString: input.newString.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n'),
152
150
  }
153
151
  }
154
152
 
155
- const oldString = input.old_string ?? input.oldStr
156
- if (typeof oldString !== 'string') {
157
- return { success: false, error: 'Either old_string/oldStr or insertLine is required.' }
158
- }
159
- return {
160
- success: true,
161
- operation: {
162
- operation: 'replace',
163
- oldString,
164
- newString,
165
- replace_all: input.replace_all,
166
- },
167
- }
168
- }
169
-
170
- function normalizeInsertLine(
171
- value: unknown,
172
- ): { success: true; value: number | 'end' } | { success: false; error: string } {
173
- if (value === 'end') return { success: true, value: 'end' }
174
- if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
175
- return { success: true, value }
176
- }
177
- return {
178
- success: false,
179
- error: 'insertLine must be a non-negative line number or "end".',
153
+ const usesOnlyLf = content.includes('\n') && !content.includes('\r\n')
154
+ if (usesOnlyLf) {
155
+ return {
156
+ ...input,
157
+ oldString: content.includes(input.oldString)
158
+ ? input.oldString
159
+ : input.oldString.replaceAll('\r\n', '\n'),
160
+ newString: input.newString.replaceAll('\r\n', '\n'),
161
+ }
180
162
  }
163
+ return input
181
164
  }
182
165
 
183
166
  function applyEdit(
184
167
  content: string,
185
- input: NormalizedEditInput,
168
+ rawInput: ExactReplacement,
186
169
  ): { success: true; content: string; replacements: number } | { success: false; error: string } {
187
- if (input.operation === 'insert') {
188
- return applyLineInsert(content, input)
189
- }
190
-
170
+ const input = normalizeLineEndings(content, rawInput)
191
171
  if (!content.includes(input.oldString)) {
192
172
  return {
193
173
  success: false,
194
174
  error:
195
- 'old_string/oldStr not found in file. Make sure the string matches exactly, including whitespace and indentation.',
175
+ 'old_string was not found. Read the file again and copy exact text without line-number prefixes.',
196
176
  }
197
177
  }
198
178
 
199
- if (input.replace_all) {
179
+ if (input.replaceAll) {
200
180
  const parts = content.split(input.oldString)
201
- const replacements = parts.length - 1
202
181
  return {
203
182
  success: true,
204
183
  content: parts.join(input.newString),
205
- replacements,
184
+ replacements: parts.length - 1,
206
185
  }
207
186
  }
208
187
 
209
- // Uniqueness check: old_string/oldStr must appear exactly once
210
188
  const firstIndex = content.indexOf(input.oldString)
211
- const secondIndex = content.indexOf(input.oldString, firstIndex + 1)
212
-
189
+ const secondIndex = content.indexOf(input.oldString, firstIndex + input.oldString.length)
213
190
  if (secondIndex !== -1) {
214
- const lineNumber = content.slice(0, firstIndex).split('\n').length
191
+ const firstLine = content.slice(0, firstIndex).split('\n').length
215
192
  const secondLine = content.slice(0, secondIndex).split('\n').length
216
193
  return {
217
194
  success: false,
218
- error: `old_string/oldStr is not unique — found at lines ${lineNumber} and ${secondLine}. Provide more surrounding context to make it unique, or use replace_all: true.`,
195
+ error: `old_string is not unique — found at lines ${firstLine} and ${secondLine}. Include more surrounding context, or use replace_all: true.`,
219
196
  }
220
197
  }
221
198
 
@@ -228,26 +205,3 @@ function applyEdit(
228
205
  replacements: 1,
229
206
  }
230
207
  }
231
-
232
- function applyLineInsert(
233
- content: string,
234
- input: Extract<NormalizedEditInput, { operation: 'insert' }>,
235
- ): { success: true; content: string; replacements: number } {
236
- const hasTrailingNewline = content.endsWith('\n')
237
- const lines = content.split('\n')
238
- if (hasTrailingNewline) lines.pop()
239
-
240
- const line =
241
- input.insertLine === 'end'
242
- ? lines.length
243
- : Math.min(Math.max(input.insertLine, 0), lines.length)
244
- const inserted = input.newString.endsWith('\n')
245
- ? input.newString.slice(0, -1).split('\n')
246
- : input.newString.split('\n')
247
- lines.splice(line, 0, ...inserted)
248
- return {
249
- success: true,
250
- content: `${lines.join('\n')}${hasTrailingNewline ? '\n' : ''}`,
251
- replacements: 1,
252
- }
253
- }
@@ -0,0 +1,27 @@
1
+ const mutationTails = new Map<string, Promise<void>>()
2
+
3
+ /**
4
+ * Serialize read-modify-write filesystem operations by resolved path within
5
+ * one SDK process. Distributed hosts must still assign one writer per file or
6
+ * provide a storage-level compare-and-swap primitive.
7
+ */
8
+ export async function withFileMutationLock<T>(
9
+ key: string,
10
+ operation: () => Promise<T>,
11
+ ): Promise<T> {
12
+ const previous = mutationTails.get(key) ?? Promise.resolve()
13
+ let release = () => {}
14
+ const current = new Promise<void>((resolve) => {
15
+ release = resolve
16
+ })
17
+ const tail = previous.then(() => current)
18
+ mutationTails.set(key, tail)
19
+
20
+ await previous
21
+ try {
22
+ return await operation()
23
+ } finally {
24
+ release()
25
+ if (mutationTails.get(key) === tail) mutationTails.delete(key)
26
+ }
27
+ }
@@ -26,8 +26,8 @@ import { WriteFileTool } from './write-file.js'
26
26
  // `code.claude.com/docs/en/tools-reference`) does NOT include `LS` —
27
27
  // directory listing is canonical `Bash` + `Glob`. `search_tools` has no
28
28
  // Claude analogue at all. Including these in the defaults gives the model
29
- // tools that look right but degrade alignment. File extension is canonical
30
- // `edit` with `insertLine: "end"` the legacy `Append` tool is gone.
29
+ // tools that look right but degrade alignment. File extension uses canonical
30
+ // exact replacement against a unique tail or deterministic marker.
31
31
  // Hosts that genuinely want LS/search can still register them explicitly.
32
32
 
33
33
  export function getBuiltinTools(): ToolDefinition[] {
@@ -1,41 +1,53 @@
1
- import { access, mkdir, writeFile } from 'node:fs/promises'
2
- import { dirname, resolve } from 'node:path'
1
+ import { access } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import type { ToolContext } from '../../types/tool/index.js'
5
5
  import { defineTool } from '../defineTool.js'
6
+ import { atomicWriteFile } from './atomic-write-file.js'
7
+ import { withFileMutationLock } from './file-mutation-lock.js'
6
8
 
7
9
  const inputSchema = z
8
10
  .object({
9
11
  path: z
10
12
  .string()
11
- .min(1)
13
+ .refine((value) => value.trim().length > 0, 'Path must not be empty.')
12
14
  .describe(
13
- 'Relative path to the file to write (e.g. "outputs/report.md"). Required. Must be a non-empty string.',
15
+ 'Relative path to the file to write (e.g. "outputs/report.md"). Must not be empty.',
14
16
  ),
15
17
  content: z
16
18
  .string()
17
- .optional()
18
19
  .describe(
19
- 'Full file body to write. Required (use "" only for an intentionally empty file). The file is fully overwritten — pass the COMPLETE intended content for this bounded chunk, not a diff. Self-budget content under 12000 characters before calling; if the intended body is longer, write a smaller opening section here, then use `edit` with insertLine: "end" to extend the file section by section. Do NOT try to chain multiple `write` calls, since each one overwrites the previous.',
20
- ),
21
- newStr: z
22
- .string()
23
- .optional()
24
- .describe(
25
- 'Alias for content. Useful for hosts that expose create/write operations as newStr. Self-budget this payload under 12000 characters before calling.',
20
+ 'Complete bounded file body. Use "" only for an intentionally empty file. Keep under 12000 characters. For longer documents, include a deterministic marker such as {{CHUNK_001}} and advance it with exact edit calls.',
26
21
  ),
27
22
  })
28
- .refine((value) => typeof value.content === 'string' || typeof value.newStr === 'string', {
29
- message: 'Either content or newStr is required.',
30
- })
23
+ .strict()
31
24
 
32
25
  type WriteInput = z.infer<typeof inputSchema>
33
26
 
27
+ const modelInputSchema: Record<string, unknown> = {
28
+ type: 'object',
29
+ properties: {
30
+ path: {
31
+ type: 'string',
32
+ description: 'Non-empty path to the file to write.',
33
+ },
34
+ content: {
35
+ type: 'string',
36
+ description: 'Complete bounded file body. May be empty only for an intentionally empty file.',
37
+ },
38
+ },
39
+ required: ['path', 'content'],
40
+ additionalProperties: false,
41
+ }
42
+
34
43
  export const WriteFileTool = defineTool({
35
44
  name: 'write',
36
45
  description:
37
- 'Writes a file to the local filesystem. Overwrites the existing file at the path if there is one.\n\n- If the file already exists, you must use the `read` tool on it first in this conversation, or this call will fail.\n- Prefer the `edit` tool for modifying existing files — it only sends the diff and preserves the rest of the file byte-for-byte.\n- Use `write` to create a new file or to perform a deliberate full rewrite of a file you have already read.\n- Self-budget content/newStr under 12000 characters before emitting the tool call. For long content, write a smaller opening section, then use `edit` with insertLine: "end" to extend the file section by section. Do not chain multiple `write` calls each one overwrites the previous.',
46
+ 'Writes a complete bounded file body. Pass exactly path + content. If the file exists, read it first; prefer edit for targeted changes. Self-budget content under 12000 characters. For longer documents, write an opening with a deterministic marker such as {{CHUNK_001}}, then use exact edit calls that advance the marker one chunk at a time. Do not chain write calls because each overwrites the file.',
38
47
  inputSchema,
48
+ modelInputSchema,
49
+ enforceModelInput: true,
50
+ validationErrorHint: 'Required shape: {"path":"file.md","content":"complete bounded file body"}.',
39
51
  category: 'filesystem',
40
52
  permissions: ['file_write'],
41
53
  readOnly: false,
@@ -43,40 +55,52 @@ export const WriteFileTool = defineTool({
43
55
  concurrencySafe: false,
44
56
 
45
57
  async execute(input: WriteInput, context) {
46
- const content = input.content ?? input.newStr ?? ''
47
- // Sandbox-aware: route through sandbox.writeFile() when available
48
- if (context.sandbox) {
49
- const sandboxExists = await sandboxFileExists(context, input.path)
50
- if (sandboxExists) {
51
- const guard = enforceReadBeforeOverwrite(context, input.path)
52
- if (guard) return guard
53
- }
54
- await context.sandbox.writeFile(input.path, content)
55
- context.fileReadTracker?.recordRead(input.path)
58
+ const parsed = inputSchema.safeParse(input)
59
+ if (!parsed.success) {
56
60
  return {
57
- success: true,
58
- output: `File written successfully: ${input.path} (${content.length} chars) [sandboxed]`,
59
- data: { path: input.path, size: content.length, sandboxed: true },
61
+ success: false,
62
+ output: '',
63
+ error: `Invalid write input: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`,
60
64
  }
61
65
  }
66
+ const canonicalInput = parsed.data
62
67
 
63
- const filePath = resolve(context.workingDirectory, input.path)
64
-
65
- const localExists = await pathExists(filePath)
66
- if (localExists) {
67
- const guard = enforceReadBeforeOverwrite(context, filePath)
68
- if (guard) return guard
69
- }
68
+ const filePath = resolve(context.workingDirectory, canonicalInput.path)
69
+ const lockKey = `${context.sandbox ? 'sandbox' : 'local'}:${filePath}`
70
+ return withFileMutationLock(lockKey, async () => {
71
+ if (context.sandbox) {
72
+ const sandboxExists = await sandboxFileExists(context, canonicalInput.path)
73
+ if (sandboxExists) {
74
+ const guard = enforceReadBeforeOverwrite(context, canonicalInput.path)
75
+ if (guard) return guard
76
+ }
77
+ await context.sandbox.writeFile(canonicalInput.path, canonicalInput.content)
78
+ context.fileReadTracker?.recordRead(canonicalInput.path)
79
+ return {
80
+ success: true as const,
81
+ output: `File written successfully: ${canonicalInput.path} (${canonicalInput.content.length} chars) [sandboxed]`,
82
+ data: {
83
+ path: canonicalInput.path,
84
+ size: canonicalInput.content.length,
85
+ sandboxed: true,
86
+ },
87
+ }
88
+ }
70
89
 
71
- await mkdir(dirname(filePath), { recursive: true })
72
- await writeFile(filePath, content, 'utf-8')
73
- context.fileReadTracker?.recordRead(filePath)
90
+ const localExists = await pathExists(filePath)
91
+ if (localExists) {
92
+ const guard = enforceReadBeforeOverwrite(context, filePath)
93
+ if (guard) return guard
94
+ }
74
95
 
75
- return {
76
- success: true,
77
- output: `File written successfully: ${filePath} (${content.length} chars)`,
78
- data: { path: filePath, size: content.length },
79
- }
96
+ await atomicWriteFile(filePath, canonicalInput.content)
97
+ context.fileReadTracker?.recordRead(filePath)
98
+ return {
99
+ success: true as const,
100
+ output: `File written successfully: ${filePath} (${canonicalInput.content.length} chars)`,
101
+ data: { path: filePath, size: canonicalInput.content.length },
102
+ }
103
+ })
80
104
  },
81
105
  })
82
106
 
@@ -11,6 +11,8 @@ export interface DefineToolOptions<S extends z.ZodType> {
11
11
  name: string
12
12
  description: string
13
13
  inputSchema: S
14
+ modelInputSchema?: Record<string, unknown>
15
+ enforceModelInput?: boolean
14
16
  validationErrorHint?: string
15
17
  category: ToolDefinition['category']
16
18
  permissions: ToolPermission[]
@@ -30,6 +32,8 @@ export function defineTool<S extends z.ZodType>(
30
32
  name: options.name,
31
33
  description: options.description,
32
34
  inputSchema: options.inputSchema,
35
+ modelInputSchema: options.modelInputSchema,
36
+ enforceModelInput: options.enforceModelInput,
33
37
  validationErrorHint: options.validationErrorHint,
34
38
  tier: options.tier,
35
39
  category: options.category,
@@ -12,7 +12,11 @@ export type ResponseFormat =
12
12
  | { type: 'json_object' }
13
13
  | {
14
14
  type: 'json_schema'
15
- json_schema: { name: string; schema: Record<string, unknown>; strict?: boolean }
15
+ json_schema: {
16
+ name: string
17
+ schema: Record<string, unknown>
18
+ strict?: boolean
19
+ }
16
20
  }
17
21
 
18
22
  export interface CacheControl {
@@ -23,6 +27,15 @@ export interface ChatCompletionParams {
23
27
  model: string
24
28
  messages: Message[]
25
29
  tools?: LLMToolSchema[]
30
+ /**
31
+ * Provider hint naming tools whose model-facing JSON Schema should be
32
+ * enforced through constrained generation when the selected transport and
33
+ * model support it.
34
+ *
35
+ * This is not a wire field. Provider implementations must consume or omit
36
+ * it instead of serializing ChatCompletionParams wholesale.
37
+ */
38
+ enforceToolInputSchema?: readonly string[]
26
39
  temperature?: number
27
40
  maxTokens?: number
28
41
  stream?: boolean
@@ -93,6 +93,10 @@ export interface Sandbox {
93
93
  readonly rootDir: string
94
94
  readonly environment: SandboxEnvironment
95
95
  exec(command: string, args?: string[], opts?: SandboxExecOptions): Promise<SandboxExecResult>
96
+ /**
97
+ * Atomically replace the destination body: readers must observe either the
98
+ * previous complete body or the new complete body, never a truncated file.
99
+ */
96
100
  writeFile(path: string, content: string | Buffer): Promise<void>
97
101
  readFile(path: string): Promise<Buffer>
98
102
  /**
@@ -64,6 +64,20 @@ export interface ToolDefinition<TInput = unknown> {
64
64
  name: string
65
65
  description: string
66
66
  inputSchema: z.ZodType<TInput, z.ZodTypeDef, unknown>
67
+ /**
68
+ * Optional canonical JSON Schema shown to models instead of the runtime
69
+ * Zod schema. Use this when runtime compatibility accepts aliases or
70
+ * constraints that should not be advertised to a model.
71
+ *
72
+ * This is intentionally independent of TInput: the model-facing contract
73
+ * may be narrower than the execution decoder.
74
+ */
75
+ modelInputSchema?: Record<string, unknown>
76
+ /**
77
+ * Ask capable providers to constrain generated input to modelInputSchema.
78
+ * ToolRegistry rejects this flag unless modelInputSchema is also present.
79
+ */
80
+ enforceModelInput?: boolean
67
81
  /**
68
82
  * Concise, model-readable recovery guidance appended when inputSchema
69
83
  * rejects a call. Use for conditional schemas whose required shapes