@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.
- package/CHANGELOG.md +19 -0
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +40 -10
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/registry/tool/execute.test.js +93 -7
- package/dist/registry/tool/execute.test.js.map +1 -1
- package/dist/registry/toolset/catalog.d.ts.map +1 -1
- package/dist/registry/toolset/catalog.js +17 -4
- package/dist/registry/toolset/catalog.js.map +1 -1
- package/dist/registry/toolset/catalog.test.js +101 -0
- package/dist/registry/toolset/catalog.test.js.map +1 -1
- package/dist/runtime/query/__tests__/capability-negotiation.test.js +79 -0
- package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -1
- package/dist/runtime/query/__tests__/deferred-tools.test.js +100 -0
- package/dist/runtime/query/__tests__/deferred-tools.test.js.map +1 -1
- package/dist/runtime/query/__tests__/long-document-flow.test.js +12 -12
- package/dist/runtime/query/__tests__/long-document-flow.test.js.map +1 -1
- package/dist/runtime/query/__tests__/stream-recovery.test.js +1 -1
- package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
- package/dist/runtime/query/executor.js +2 -2
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +10 -0
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/tools/builtins/__tests__/edit.test.js +206 -54
- package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
- package/dist/tools/builtins/__tests__/payload-budget.test.js +30 -16
- package/dist/tools/builtins/__tests__/payload-budget.test.js.map +1 -1
- package/dist/tools/builtins/__tests__/write-file.test.js +37 -13
- package/dist/tools/builtins/__tests__/write-file.test.js.map +1 -1
- package/dist/tools/builtins/atomic-write-file.d.ts +13 -0
- package/dist/tools/builtins/atomic-write-file.d.ts.map +1 -0
- package/dist/tools/builtins/atomic-write-file.js +46 -0
- package/dist/tools/builtins/atomic-write-file.js.map +1 -0
- package/dist/tools/builtins/bash.js +2 -2
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/edit.d.ts +2 -5
- package/dist/tools/builtins/edit.d.ts.map +1 -1
- package/dist/tools/builtins/edit.js +107 -125
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/tools/builtins/file-mutation-lock.d.ts +7 -0
- package/dist/tools/builtins/file-mutation-lock.d.ts.map +1 -0
- package/dist/tools/builtins/file-mutation-lock.js +25 -0
- package/dist/tools/builtins/file-mutation-lock.js.map +1 -0
- package/dist/tools/builtins/index.js +2 -2
- package/dist/tools/builtins/index.js.map +1 -1
- package/dist/tools/builtins/write-file.d.ts +1 -2
- package/dist/tools/builtins/write-file.d.ts.map +1 -1
- package/dist/tools/builtins/write-file.js +66 -40
- package/dist/tools/builtins/write-file.js.map +1 -1
- package/dist/tools/defineTool.d.ts +2 -0
- package/dist/tools/defineTool.d.ts.map +1 -1
- package/dist/tools/defineTool.js +2 -0
- package/dist/tools/defineTool.js.map +1 -1
- package/dist/types/provider/chat.d.ts +9 -0
- package/dist/types/provider/chat.d.ts.map +1 -1
- package/dist/types/sandbox/index.d.ts +4 -0
- package/dist/types/sandbox/index.d.ts.map +1 -1
- package/dist/types/sandbox/index.js.map +1 -1
- package/dist/types/tool/index.d.ts +14 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/registry/tool/execute.test.ts +107 -7
- package/src/registry/tool/execute.ts +45 -10
- package/src/registry/toolset/catalog.test.ts +118 -0
- package/src/registry/toolset/catalog.ts +26 -4
- package/src/runtime/query/__tests__/capability-negotiation.test.ts +88 -0
- package/src/runtime/query/__tests__/deferred-tools.test.ts +111 -0
- package/src/runtime/query/__tests__/long-document-flow.test.ts +12 -12
- package/src/runtime/query/__tests__/stream-recovery.test.ts +1 -1
- package/src/runtime/query/executor.ts +2 -2
- package/src/runtime/query/iteration/index.ts +15 -0
- package/src/tools/builtins/__tests__/edit.test.ts +248 -64
- package/src/tools/builtins/__tests__/payload-budget.test.ts +40 -20
- package/src/tools/builtins/__tests__/write-file.test.ts +52 -13
- package/src/tools/builtins/atomic-write-file.ts +58 -0
- package/src/tools/builtins/bash.ts +2 -2
- package/src/tools/builtins/edit.ts +117 -163
- package/src/tools/builtins/file-mutation-lock.ts +27 -0
- package/src/tools/builtins/index.ts +2 -2
- package/src/tools/builtins/write-file.ts +68 -44
- package/src/tools/defineTool.ts +4 -0
- package/src/types/provider/chat.ts +14 -1
- package/src/types/sandbox/index.ts +4 -0
- package/src/types/tool/index.ts +14 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { describe, expect, it } from 'vitest'
|
|
5
|
-
import { zodToJsonSchema } from 'zod-to-json-schema'
|
|
6
5
|
import { ToolRegistry } from '../../../registry/tool/execute.js'
|
|
6
|
+
import type { Sandbox } from '../../../types/sandbox/index.js'
|
|
7
7
|
import type { ToolContext } from '../../../types/tool/index.js'
|
|
8
|
+
import { atomicWriteFile } from '../atomic-write-file.js'
|
|
8
9
|
import { EditTool } from '../edit.js'
|
|
9
10
|
|
|
10
11
|
function makeContext(workingDirectory: string): ToolContext {
|
|
@@ -18,12 +19,75 @@ function makeContext(workingDirectory: string): ToolContext {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
describe('EditTool', () => {
|
|
21
|
-
it('
|
|
22
|
+
it('publishes one closed canonical replacement contract', () => {
|
|
23
|
+
const schema = EditTool.modelInputSchema
|
|
24
|
+
expect(schema).toEqual({
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
path: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Path to the file to edit. Must not be empty.',
|
|
30
|
+
},
|
|
31
|
+
old_string: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description:
|
|
34
|
+
'Exact unique text from the file, without read-tool line-number prefixes. Must not be empty.',
|
|
35
|
+
},
|
|
36
|
+
new_string: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description:
|
|
39
|
+
'Exact replacement text. May be empty to delete old_string. Keep under 12000 characters.',
|
|
40
|
+
},
|
|
41
|
+
replace_all: {
|
|
42
|
+
type: 'boolean',
|
|
43
|
+
description: 'Replace every occurrence instead of requiring one unique match.',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
required: ['path', 'old_string', 'new_string'],
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
expect(
|
|
51
|
+
EditTool.inputSchema.safeParse({
|
|
52
|
+
path: 'doc.md',
|
|
53
|
+
old_string: 'old',
|
|
54
|
+
new_string: 'new',
|
|
55
|
+
replace_all: true,
|
|
56
|
+
}).success,
|
|
57
|
+
).toBe(true)
|
|
58
|
+
|
|
59
|
+
for (const legacy of [
|
|
60
|
+
{ path: 'doc.md', oldStr: 'old', newStr: 'new' },
|
|
61
|
+
{ path: 'doc.md', insertLine: 'end', new_string: 'append' },
|
|
62
|
+
{
|
|
63
|
+
path: 'doc.md',
|
|
64
|
+
old_string: 'old',
|
|
65
|
+
new_string: 'new',
|
|
66
|
+
newStr: 'legacy',
|
|
67
|
+
},
|
|
68
|
+
]) {
|
|
69
|
+
expect(EditTool.inputSchema.safeParse(legacy).success, JSON.stringify(legacy)).toBe(false)
|
|
70
|
+
}
|
|
71
|
+
expect(
|
|
72
|
+
EditTool.inputSchema.safeParse({
|
|
73
|
+
path: ' ',
|
|
74
|
+
old_string: 'old',
|
|
75
|
+
new_string: 'new',
|
|
76
|
+
}).success,
|
|
77
|
+
).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('replaces one exact unique string', async () => {
|
|
22
81
|
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
23
82
|
writeFileSync(join(dir, 'doc.md'), 'alpha\nbeta\n')
|
|
24
83
|
|
|
25
84
|
const result = await EditTool.execute(
|
|
26
|
-
{
|
|
85
|
+
{
|
|
86
|
+
path: 'doc.md',
|
|
87
|
+
old_string: 'beta',
|
|
88
|
+
new_string: 'gamma',
|
|
89
|
+
replace_all: false,
|
|
90
|
+
},
|
|
27
91
|
makeContext(dir),
|
|
28
92
|
)
|
|
29
93
|
|
|
@@ -31,25 +95,36 @@ describe('EditTool', () => {
|
|
|
31
95
|
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\ngamma\n')
|
|
32
96
|
})
|
|
33
97
|
|
|
34
|
-
it('
|
|
98
|
+
it('validates a path without rewriting edge whitespace', async () => {
|
|
35
99
|
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
36
|
-
writeFileSync(join(dir, 'doc.md'), 'alpha\
|
|
100
|
+
writeFileSync(join(dir, ' doc.md '), 'alpha\n')
|
|
37
101
|
|
|
38
102
|
const result = await EditTool.execute(
|
|
39
|
-
{
|
|
103
|
+
{
|
|
104
|
+
path: ' doc.md ',
|
|
105
|
+
old_string: 'alpha',
|
|
106
|
+
new_string: 'beta',
|
|
107
|
+
replace_all: false,
|
|
108
|
+
},
|
|
40
109
|
makeContext(dir),
|
|
41
110
|
)
|
|
42
111
|
|
|
43
112
|
expect(result.success).toBe(true)
|
|
44
|
-
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('
|
|
113
|
+
expect(readFileSync(join(dir, ' doc.md '), 'utf-8')).toBe('beta\n')
|
|
114
|
+
expect(existsSync(join(dir, 'doc.md'))).toBe(false)
|
|
45
115
|
})
|
|
46
116
|
|
|
47
|
-
it('
|
|
117
|
+
it('allows deletion with an empty new_string', async () => {
|
|
48
118
|
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
49
|
-
writeFileSync(join(dir, 'doc.md'), 'alpha\n')
|
|
119
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha\nremove me\nomega\n')
|
|
50
120
|
|
|
51
121
|
const result = await EditTool.execute(
|
|
52
|
-
{
|
|
122
|
+
{
|
|
123
|
+
path: 'doc.md',
|
|
124
|
+
old_string: 'remove me\n',
|
|
125
|
+
new_string: '',
|
|
126
|
+
replace_all: false,
|
|
127
|
+
},
|
|
53
128
|
makeContext(dir),
|
|
54
129
|
)
|
|
55
130
|
|
|
@@ -57,77 +132,186 @@ describe('EditTool', () => {
|
|
|
57
132
|
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\nomega\n')
|
|
58
133
|
})
|
|
59
134
|
|
|
60
|
-
it('
|
|
61
|
-
|
|
62
|
-
|
|
135
|
+
it('replaces every exact occurrence only when replace_all is true', async () => {
|
|
136
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
137
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha alpha alpha')
|
|
138
|
+
|
|
139
|
+
const result = await EditTool.execute(
|
|
140
|
+
{
|
|
63
141
|
path: 'doc.md',
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
142
|
+
old_string: 'alpha',
|
|
143
|
+
new_string: 'beta',
|
|
144
|
+
replace_all: true,
|
|
145
|
+
},
|
|
146
|
+
makeContext(dir),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
expect(result.success).toBe(true)
|
|
150
|
+
expect(result.data).toMatchObject({ replacements: 3 })
|
|
151
|
+
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('beta beta beta')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('refuses ambiguous exact matches instead of guessing', async () => {
|
|
155
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
156
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha\nalpha\n')
|
|
69
157
|
|
|
70
|
-
|
|
71
|
-
|
|
158
|
+
const result = await EditTool.execute(
|
|
159
|
+
{
|
|
72
160
|
path: 'doc.md',
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
161
|
+
old_string: 'alpha',
|
|
162
|
+
new_string: 'beta',
|
|
163
|
+
replace_all: false,
|
|
164
|
+
},
|
|
165
|
+
makeContext(dir),
|
|
166
|
+
)
|
|
78
167
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}) as {
|
|
83
|
-
properties?: {
|
|
84
|
-
insertLine?: {
|
|
85
|
-
anyOf?: Record<string, unknown>[]
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
expect(json.properties?.insertLine?.anyOf).toEqual([
|
|
90
|
-
{ type: 'integer', minimum: 0 },
|
|
91
|
-
{ type: 'string', const: 'end' },
|
|
92
|
-
])
|
|
168
|
+
expect(result.success).toBe(false)
|
|
169
|
+
expect(result.error).toContain('old_string is not unique')
|
|
170
|
+
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\nalpha\n')
|
|
93
171
|
})
|
|
94
172
|
|
|
95
|
-
it('
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
173
|
+
it('normalizes consistent CRLF/LF boundaries without fuzzy matching', async () => {
|
|
174
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
175
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha\r\nbeta\r\nomega\r\n')
|
|
176
|
+
|
|
177
|
+
const result = await EditTool.execute(
|
|
178
|
+
{
|
|
179
|
+
path: 'doc.md',
|
|
180
|
+
old_string: 'alpha\nbeta',
|
|
181
|
+
new_string: 'gamma\ndelta',
|
|
182
|
+
replace_all: false,
|
|
183
|
+
},
|
|
184
|
+
makeContext(dir),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
expect(result.success).toBe(true)
|
|
188
|
+
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('gamma\r\ndelta\r\nomega\r\n')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('rejects legacy and extra fields even when execute is called directly', async () => {
|
|
192
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
193
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha\n')
|
|
194
|
+
|
|
195
|
+
for (const input of [
|
|
196
|
+
{ path: 'doc.md', oldStr: 'alpha', newStr: 'beta' },
|
|
197
|
+
{
|
|
198
|
+
path: 'doc.md',
|
|
199
|
+
old_string: 'alpha',
|
|
200
|
+
new_string: 'beta',
|
|
201
|
+
insertLine: 'end',
|
|
202
|
+
},
|
|
203
|
+
]) {
|
|
204
|
+
const result = await EditTool.execute(input as never, makeContext(dir))
|
|
205
|
+
expect(result.success, JSON.stringify(input)).toBe(false)
|
|
206
|
+
expect(result.error).toContain('Invalid edit input')
|
|
113
207
|
}
|
|
208
|
+
|
|
209
|
+
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\n')
|
|
114
210
|
})
|
|
115
211
|
|
|
116
|
-
it('returns
|
|
212
|
+
it('returns one canonical recovery shape after registry validation fails', async () => {
|
|
117
213
|
const registry = new ToolRegistry()
|
|
118
214
|
registry.register(EditTool)
|
|
119
215
|
|
|
120
216
|
const result = await registry.execute(
|
|
121
217
|
'edit',
|
|
122
|
-
{ insertLine: '
|
|
218
|
+
{ insertLine: '"end"', newStr: 'content' },
|
|
123
219
|
makeContext('/tmp'),
|
|
124
220
|
)
|
|
125
221
|
|
|
126
222
|
expect(result.success).toBe(false)
|
|
127
223
|
expect(result.error).toContain('Validation failed for "edit":')
|
|
128
|
-
expect(result.error).toContain('
|
|
129
|
-
expect(result.error).toContain(
|
|
130
|
-
|
|
131
|
-
|
|
224
|
+
expect(result.error).toContain('Required: path: string')
|
|
225
|
+
expect(result.error).toContain(
|
|
226
|
+
'{"path":"file.md","old_string":"exact unique text","new_string":"replacement text"}',
|
|
227
|
+
)
|
|
228
|
+
expect(result.error).not.toContain('Accepted shapes')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('serializes independent EditTool read-modify-write cycles for one sandbox path', async () => {
|
|
232
|
+
let body = Buffer.from('alpha')
|
|
233
|
+
let reads = 0
|
|
234
|
+
let writes = 0
|
|
235
|
+
let releaseFirstWrite = () => {}
|
|
236
|
+
let markFirstWriteStarted = () => {}
|
|
237
|
+
const firstWriteStarted = new Promise<void>((resolve) => {
|
|
238
|
+
markFirstWriteStarted = resolve
|
|
239
|
+
})
|
|
240
|
+
const firstWriteGate = new Promise<void>((resolve) => {
|
|
241
|
+
releaseFirstWrite = resolve
|
|
242
|
+
})
|
|
243
|
+
const sandbox = {
|
|
244
|
+
id: 'sbx_edit_lock',
|
|
245
|
+
status: 'ready',
|
|
246
|
+
rootDir: '/workspace',
|
|
247
|
+
environment: 'basic',
|
|
248
|
+
async readFile() {
|
|
249
|
+
reads += 1
|
|
250
|
+
return Buffer.from(body)
|
|
251
|
+
},
|
|
252
|
+
async writeFile(_path: string, content: string | Buffer) {
|
|
253
|
+
writes += 1
|
|
254
|
+
if (writes === 1) {
|
|
255
|
+
markFirstWriteStarted()
|
|
256
|
+
await firstWriteGate
|
|
257
|
+
}
|
|
258
|
+
body = Buffer.isBuffer(content) ? Buffer.from(content) : Buffer.from(content)
|
|
259
|
+
},
|
|
260
|
+
async exec() {
|
|
261
|
+
throw new Error('not used')
|
|
262
|
+
},
|
|
263
|
+
async listFiles() {
|
|
264
|
+
return []
|
|
265
|
+
},
|
|
266
|
+
async destroy() {},
|
|
267
|
+
} as Sandbox
|
|
268
|
+
const firstContext = { ...makeContext('/workspace'), sandbox }
|
|
269
|
+
const secondContext = { ...makeContext('/workspace'), sandbox }
|
|
270
|
+
|
|
271
|
+
const first = EditTool.execute(
|
|
272
|
+
{
|
|
273
|
+
path: 'doc.md',
|
|
274
|
+
old_string: 'alpha',
|
|
275
|
+
new_string: 'beta',
|
|
276
|
+
replace_all: false,
|
|
277
|
+
},
|
|
278
|
+
firstContext,
|
|
279
|
+
)
|
|
280
|
+
await firstWriteStarted
|
|
281
|
+
const second = EditTool.execute(
|
|
282
|
+
{
|
|
283
|
+
path: 'doc.md',
|
|
284
|
+
old_string: 'beta',
|
|
285
|
+
new_string: 'gamma',
|
|
286
|
+
replace_all: false,
|
|
287
|
+
},
|
|
288
|
+
secondContext,
|
|
289
|
+
)
|
|
290
|
+
await Promise.resolve()
|
|
291
|
+
expect(reads).toBe(1)
|
|
292
|
+
|
|
293
|
+
releaseFirstWrite()
|
|
294
|
+
const results = await Promise.all([first, second])
|
|
295
|
+
expect(results.every((result) => result.success)).toBe(true)
|
|
296
|
+
expect(reads).toBe(2)
|
|
297
|
+
expect(writes).toBe(2)
|
|
298
|
+
expect(body.toString('utf-8')).toBe('gamma')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('leaves the original intact when an atomic local write fails before commit', async () => {
|
|
302
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-atomic-write-'))
|
|
303
|
+
const filePath = join(dir, 'doc.md')
|
|
304
|
+
writeFileSync(filePath, 'original')
|
|
305
|
+
|
|
306
|
+
await expect(
|
|
307
|
+
atomicWriteFile(filePath, 'replacement', {
|
|
308
|
+
beforeCommit: async () => {
|
|
309
|
+
throw new Error('injected pre-commit failure')
|
|
310
|
+
},
|
|
311
|
+
}),
|
|
312
|
+
).rejects.toThrow('injected pre-commit failure')
|
|
313
|
+
|
|
314
|
+
expect(readFileSync(filePath, 'utf-8')).toBe('original')
|
|
315
|
+
expect(readdirSync(dir).filter((entry) => entry.includes('.namzu-'))).toEqual([])
|
|
132
316
|
})
|
|
133
317
|
})
|
|
@@ -17,8 +17,8 @@ describe('filesystem tool payload budgeting', () => {
|
|
|
17
17
|
expect(() =>
|
|
18
18
|
EditTool.inputSchema.parse({
|
|
19
19
|
path: 'outputs/long.md',
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
old_string: '{{SECTION}}',
|
|
21
|
+
new_string: oversized,
|
|
22
22
|
replace_all: false,
|
|
23
23
|
}),
|
|
24
24
|
).not.toThrow()
|
|
@@ -31,7 +31,7 @@ describe('filesystem tool payload budgeting', () => {
|
|
|
31
31
|
expect(names).not.toContain('append')
|
|
32
32
|
})
|
|
33
33
|
|
|
34
|
-
it('assembles long documents
|
|
34
|
+
it('assembles replay-safe long documents by advancing an exact marker', async () => {
|
|
35
35
|
const dir = mkdtempSync(join(tmpdir(), 'namzu-long-doc-'))
|
|
36
36
|
const ctx: ToolContext = {
|
|
37
37
|
runId: 'run_test' as ToolContext['runId'],
|
|
@@ -40,12 +40,12 @@ describe('filesystem tool payload budgeting', () => {
|
|
|
40
40
|
env: {},
|
|
41
41
|
log: () => {},
|
|
42
42
|
}
|
|
43
|
-
const opening = '# Long document regression\n\n{{
|
|
43
|
+
const opening = '# Long document regression\n\n{{CHUNK_001}}\n'
|
|
44
44
|
const chunks = Array.from({ length: 6 }, (_, sectionIndex) => {
|
|
45
45
|
const lines = Array.from({ length: 45 }, (_, lineIndex) => {
|
|
46
46
|
const section = sectionIndex + 1
|
|
47
47
|
const line = lineIndex + 1
|
|
48
|
-
return `Section ${section}.${line}: this bounded paragraph proves long-form output grows through
|
|
48
|
+
return `Section ${section}.${line}: this bounded paragraph proves long-form output grows through exact marker edits rather than one oversized JSON tool argument.`
|
|
49
49
|
})
|
|
50
50
|
const chunk = [`## Section ${sectionIndex + 1}`, ...lines, ''].join('\n')
|
|
51
51
|
expect(chunk.length).toBeLessThan(12_000)
|
|
@@ -58,32 +58,52 @@ describe('filesystem tool payload budgeting', () => {
|
|
|
58
58
|
)
|
|
59
59
|
expect(writeResult.success).toBe(true)
|
|
60
60
|
|
|
61
|
-
const
|
|
62
|
-
{
|
|
63
|
-
|
|
64
|
-
oldStr: '{{BODY}}',
|
|
65
|
-
newStr: chunks[0],
|
|
66
|
-
replace_all: false,
|
|
67
|
-
},
|
|
68
|
-
ctx,
|
|
69
|
-
)
|
|
70
|
-
expect(firstEdit.success).toBe(true)
|
|
71
|
-
|
|
72
|
-
for (const chunk of chunks.slice(1)) {
|
|
61
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
62
|
+
const currentMarker = `{{CHUNK_${String(index + 1).padStart(3, '0')}}}`
|
|
63
|
+
const nextMarker = `{{CHUNK_${String(index + 2).padStart(3, '0')}}}`
|
|
73
64
|
const result = await EditTool.execute(
|
|
74
65
|
{
|
|
75
66
|
path: 'outputs/regression-long-document.md',
|
|
76
|
-
|
|
77
|
-
|
|
67
|
+
old_string: currentMarker,
|
|
68
|
+
new_string: `${chunk}\n${nextMarker}`,
|
|
78
69
|
replace_all: false,
|
|
79
70
|
},
|
|
80
71
|
ctx,
|
|
81
72
|
)
|
|
82
73
|
expect(result.success).toBe(true)
|
|
74
|
+
|
|
75
|
+
if (index === 0) {
|
|
76
|
+
const beforeReplay = readFileSync(join(dir, 'outputs/regression-long-document.md'), 'utf-8')
|
|
77
|
+
const replay = await EditTool.execute(
|
|
78
|
+
{
|
|
79
|
+
path: 'outputs/regression-long-document.md',
|
|
80
|
+
old_string: currentMarker,
|
|
81
|
+
new_string: `${chunk}\n${nextMarker}`,
|
|
82
|
+
replace_all: false,
|
|
83
|
+
},
|
|
84
|
+
ctx,
|
|
85
|
+
)
|
|
86
|
+
expect(replay.success).toBe(false)
|
|
87
|
+
expect(readFileSync(join(dir, 'outputs/regression-long-document.md'), 'utf-8')).toBe(
|
|
88
|
+
beforeReplay,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
83
91
|
}
|
|
84
92
|
|
|
93
|
+
const finalMarker = `{{CHUNK_${String(chunks.length + 1).padStart(3, '0')}}}`
|
|
94
|
+
const finalize = await EditTool.execute(
|
|
95
|
+
{
|
|
96
|
+
path: 'outputs/regression-long-document.md',
|
|
97
|
+
old_string: finalMarker,
|
|
98
|
+
new_string: '',
|
|
99
|
+
replace_all: false,
|
|
100
|
+
},
|
|
101
|
+
ctx,
|
|
102
|
+
)
|
|
103
|
+
expect(finalize.success).toBe(true)
|
|
104
|
+
|
|
85
105
|
const final = readFileSync(join(dir, 'outputs/regression-long-document.md'), 'utf-8')
|
|
86
|
-
expect(final).not.toContain('{{
|
|
106
|
+
expect(final).not.toContain('{{CHUNK_')
|
|
87
107
|
expect(final.split('\n').length).toBeGreaterThan(250)
|
|
88
108
|
expect(final).toContain('## Section 1')
|
|
89
109
|
expect(final).toContain('## Section 6')
|
|
@@ -8,10 +8,10 @@ import { WriteFileTool } from '../write-file.js'
|
|
|
8
8
|
function makeTracker(): FileReadTracker & { keys(): string[] } {
|
|
9
9
|
const set = new Set<string>()
|
|
10
10
|
return {
|
|
11
|
-
recordRead: (
|
|
12
|
-
set.add(
|
|
11
|
+
recordRead: (key) => {
|
|
12
|
+
set.add(key)
|
|
13
13
|
},
|
|
14
|
-
hasRead: (
|
|
14
|
+
hasRead: (key) => set.has(key),
|
|
15
15
|
keys: () => Array.from(set),
|
|
16
16
|
}
|
|
17
17
|
}
|
|
@@ -27,7 +27,33 @@ function makeContext(workingDirectory: string, tracker?: FileReadTracker): ToolC
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
describe('WriteFileTool — read-before-overwrite invariant', () => {
|
|
30
|
+
describe('WriteFileTool — canonical contract and read-before-overwrite invariant', () => {
|
|
31
|
+
it('publishes one closed path + content contract', () => {
|
|
32
|
+
expect(WriteFileTool.modelInputSchema).toEqual({
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
path: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'Non-empty path to the file to write.',
|
|
38
|
+
},
|
|
39
|
+
content: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
description:
|
|
42
|
+
'Complete bounded file body. May be empty only for an intentionally empty file.',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
required: ['path', 'content'],
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
})
|
|
48
|
+
expect(WriteFileTool.inputSchema.safeParse({ path: 'doc.md', content: '' }).success).toBe(true)
|
|
49
|
+
expect(WriteFileTool.inputSchema.safeParse({ path: 'doc.md', newStr: 'legacy' }).success).toBe(
|
|
50
|
+
false,
|
|
51
|
+
)
|
|
52
|
+
expect(WriteFileTool.inputSchema.safeParse({ path: ' ', content: 'body' }).success).toBe(
|
|
53
|
+
false,
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
|
|
31
57
|
it('writes a new file without requiring a prior read', async () => {
|
|
32
58
|
const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
|
|
33
59
|
const tracker = makeTracker()
|
|
@@ -40,15 +66,28 @@ describe('WriteFileTool — read-before-overwrite invariant', () => {
|
|
|
40
66
|
expect(tracker.hasRead(join(dir, 'fresh.txt'))).toBe(true)
|
|
41
67
|
})
|
|
42
68
|
|
|
43
|
-
it('
|
|
69
|
+
it('preserves edge whitespace in a valid path', async () => {
|
|
44
70
|
const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
|
|
45
|
-
const tracker = makeTracker()
|
|
46
|
-
const ctx = makeContext(dir, tracker)
|
|
47
71
|
|
|
48
|
-
const result = await WriteFileTool.execute(
|
|
72
|
+
const result = await WriteFileTool.execute(
|
|
73
|
+
{ path: ' fresh.txt ', content: 'hello' },
|
|
74
|
+
makeContext(dir),
|
|
75
|
+
)
|
|
49
76
|
|
|
50
77
|
expect(result.success).toBe(true)
|
|
51
|
-
expect(readFileSync(join(dir, 'fresh.txt'), 'utf-8')).toBe('hello')
|
|
78
|
+
expect(readFileSync(join(dir, ' fresh.txt '), 'utf-8')).toBe('hello')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('rejects legacy content aliases even when execute is called directly', async () => {
|
|
82
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
|
|
83
|
+
|
|
84
|
+
const result = await WriteFileTool.execute(
|
|
85
|
+
{ path: 'fresh.txt', newStr: 'legacy' } as never,
|
|
86
|
+
makeContext(dir),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(result.success).toBe(false)
|
|
90
|
+
expect(result.error).toContain('Invalid write input')
|
|
52
91
|
})
|
|
53
92
|
|
|
54
93
|
it('refuses to overwrite an existing file the agent has not read', async () => {
|
|
@@ -84,14 +123,14 @@ describe('WriteFileTool — read-before-overwrite invariant', () => {
|
|
|
84
123
|
expect(readFileSync(filePath, 'utf-8')).toBe('replaced')
|
|
85
124
|
})
|
|
86
125
|
|
|
87
|
-
it('
|
|
126
|
+
it('preserves existing hosts without a file read tracker', async () => {
|
|
88
127
|
const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
|
|
89
|
-
writeFileSync(join(dir, '
|
|
128
|
+
writeFileSync(join(dir, 'existing.txt'), 'before')
|
|
90
129
|
const ctx = makeContext(dir)
|
|
91
130
|
|
|
92
|
-
const result = await WriteFileTool.execute({ path: '
|
|
131
|
+
const result = await WriteFileTool.execute({ path: 'existing.txt', content: 'after' }, ctx)
|
|
93
132
|
|
|
94
133
|
expect(result.success).toBe(true)
|
|
95
|
-
expect(readFileSync(join(dir, '
|
|
134
|
+
expect(readFileSync(join(dir, 'existing.txt'), 'utf-8')).toBe('after')
|
|
96
135
|
})
|
|
97
136
|
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { type FileHandle, mkdir, open, rename, stat, unlink } from 'node:fs/promises'
|
|
3
|
+
import { basename, dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
interface AtomicWriteHooks {
|
|
6
|
+
/** Internal fault-injection seam; production callers never pass this. */
|
|
7
|
+
beforeCommit?: () => Promise<void>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Commit a complete file body without exposing a truncated destination.
|
|
12
|
+
*
|
|
13
|
+
* The temp file lives beside the destination so rename stays on one
|
|
14
|
+
* filesystem. A failed pre-commit write leaves the original untouched.
|
|
15
|
+
*/
|
|
16
|
+
export async function atomicWriteFile(
|
|
17
|
+
filePath: string,
|
|
18
|
+
content: string,
|
|
19
|
+
hooks: AtomicWriteHooks = {},
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const directory = dirname(filePath)
|
|
22
|
+
await mkdir(directory, { recursive: true })
|
|
23
|
+
|
|
24
|
+
const existingMode = await stat(filePath)
|
|
25
|
+
.then((value) => value.mode)
|
|
26
|
+
.catch(() => undefined)
|
|
27
|
+
const tempPath = join(
|
|
28
|
+
directory,
|
|
29
|
+
`.${basename(filePath)}.namzu-${process.pid}-${randomUUID()}.tmp`,
|
|
30
|
+
)
|
|
31
|
+
let handle: FileHandle | undefined
|
|
32
|
+
let committed = false
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
handle = await open(tempPath, 'wx', existingMode ?? 0o666)
|
|
36
|
+
await handle.writeFile(content, 'utf-8')
|
|
37
|
+
await handle.sync()
|
|
38
|
+
await handle.close()
|
|
39
|
+
handle = undefined
|
|
40
|
+
await hooks.beforeCommit?.()
|
|
41
|
+
await rename(tempPath, filePath)
|
|
42
|
+
committed = true
|
|
43
|
+
await syncDirectory(directory)
|
|
44
|
+
} finally {
|
|
45
|
+
await handle?.close().catch(() => undefined)
|
|
46
|
+
if (!committed) await unlink(tempPath).catch(() => undefined)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function syncDirectory(directory: string): Promise<void> {
|
|
51
|
+
let handle: FileHandle | undefined
|
|
52
|
+
try {
|
|
53
|
+
handle = await open(directory, 'r')
|
|
54
|
+
await handle.sync()
|
|
55
|
+
} finally {
|
|
56
|
+
await handle?.close().catch(() => undefined)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -22,7 +22,7 @@ const inputSchema = z.object({
|
|
|
22
22
|
.string()
|
|
23
23
|
.min(1)
|
|
24
24
|
.describe(
|
|
25
|
-
'The bash command to execute. Required, non-empty. Single command per call (use `&&` / `;` chaining for compound commands). Avoid heredocs that span more than a few hundred bytes — large content
|
|
25
|
+
'The bash command to execute. Required, non-empty. Single command per call (use `&&` / `;` chaining for compound commands). Avoid heredocs that span more than a few hundred bytes — create large content with bounded write plus exact marker-advancing edit calls instead.',
|
|
26
26
|
),
|
|
27
27
|
timeout: z
|
|
28
28
|
.preprocess(
|
|
@@ -41,7 +41,7 @@ function isDangerousCommand(command: string): boolean {
|
|
|
41
41
|
export const BashTool = defineTool({
|
|
42
42
|
name: 'bash',
|
|
43
43
|
description:
|
|
44
|
-
'Executes a bash command and returns stdout/stderr output. Command timeout is configurable. The `command` parameter is required — never call this tool with empty arguments. For very long content
|
|
44
|
+
'Executes a bash command and returns stdout/stderr output. Command timeout is configurable. The `command` parameter is required — never call this tool with empty arguments. For very long content, prefer a bounded write with a deterministic marker followed by exact marker-advancing edit calls over a heredoc.',
|
|
45
45
|
inputSchema,
|
|
46
46
|
category: 'shell',
|
|
47
47
|
permissions: ['shell_execute'],
|