@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,78 +1,77 @@
|
|
|
1
|
-
import { readFile
|
|
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
|
|
9
|
-
old_string: z
|
|
10
|
+
path: z
|
|
10
11
|
.string()
|
|
11
|
-
.
|
|
12
|
-
.describe('
|
|
13
|
-
|
|
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
|
-
.
|
|
16
|
+
.min(1)
|
|
16
17
|
.describe(
|
|
17
|
-
'
|
|
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
|
-
'
|
|
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
|
|
28
|
+
.describe('Replace every occurrence instead of requiring one unique match.'),
|
|
41
29
|
})
|
|
42
|
-
.
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
|
84
|
-
if (!
|
|
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:
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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:
|
|
111
|
-
output:
|
|
112
|
-
|
|
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,
|
|
117
|
-
const
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
156
|
-
if (
|
|
157
|
-
return {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
168
|
+
rawInput: ExactReplacement,
|
|
186
169
|
): { success: true; content: string; replacements: number } | { success: false; error: string } {
|
|
187
|
-
|
|
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
|
|
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.
|
|
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 +
|
|
212
|
-
|
|
189
|
+
const secondIndex = content.indexOf(input.oldString, firstIndex + input.oldString.length)
|
|
213
190
|
if (secondIndex !== -1) {
|
|
214
|
-
const
|
|
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
|
|
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
|
|
30
|
-
//
|
|
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
|
|
2
|
-
import {
|
|
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
|
-
.
|
|
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").
|
|
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
|
-
'
|
|
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
|
-
.
|
|
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
|
|
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
|
|
47
|
-
|
|
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:
|
|
58
|
-
output:
|
|
59
|
-
|
|
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,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
const localExists = await pathExists(filePath)
|
|
91
|
+
if (localExists) {
|
|
92
|
+
const guard = enforceReadBeforeOverwrite(context, filePath)
|
|
93
|
+
if (guard) return guard
|
|
94
|
+
}
|
|
74
95
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
package/src/tools/defineTool.ts
CHANGED
|
@@ -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: {
|
|
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
|
/**
|
package/src/types/tool/index.ts
CHANGED
|
@@ -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
|