minimal-agent 0.18.9 → 0.18.11
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/package.json +1 -1
- package/src/advisor/advisorCoordinator.js +30 -6
- package/src/advisor/advisorCoordinator.ts +23 -6
- package/src/advisor/deliveryReview.js +23 -0
- package/src/advisor/deliveryReview.ts +22 -0
- package/src/advisor/presentation.js +3 -1
- package/src/advisor/presentation.ts +4 -1
- package/src/loop/README.md +44 -0
- package/src/loop/deliveryIntent.js +22 -0
- package/src/loop/deliveryIntent.ts +39 -0
- package/src/loop/messages.js +146 -0
- package/src/loop/messages.ts +166 -0
- package/src/loop/options.js +1 -0
- package/src/loop/options.ts +155 -0
- package/src/loop/progressStream.js +40 -0
- package/src/loop/progressStream.ts +44 -0
- package/src/loop/publication.js +94 -0
- package/src/loop/publication.ts +124 -0
- package/src/loop/requestPreparation.js +180 -0
- package/src/loop/requestPreparation.ts +226 -0
- package/src/loop/toolBatch.js +99 -0
- package/src/loop/toolBatch.ts +123 -0
- package/src/loop/toolExecutor.js +304 -0
- package/src/loop/toolExecutor.ts +363 -0
- package/src/loop/toolPresentation.js +135 -0
- package/src/loop/toolPresentation.ts +134 -0
- package/src/loop/toolSemantics.js +45 -0
- package/src/loop/toolSemantics.ts +51 -0
- package/src/loop.js +368 -1111
- package/src/loop.ts +480 -1378
- package/src/tools/advisor/advisor.js +5 -2
- package/src/tools/advisor/advisor.ts +6 -2
- package/src/tools/applypatch/applyPatch.js +1 -1
- package/src/tools/applypatch/applyPatch.ts +1 -1
- package/src/tools/bash/semantics.js +33 -1
- package/src/tools/bash/semantics.ts +27 -1
- package/src/tools/edit/edit.js +11 -15
- package/src/tools/edit/edit.ts +10 -15
- package/src/tools/edit/multi-edit.js +14 -4
- package/src/tools/edit/multi-edit.ts +10 -4
- package/src/tools/read/read.js +91 -47
- package/src/tools/read/read.ts +77 -46
- package/src/tools/shared/diffSummary.js +25 -3
- package/src/tools/shared/diffSummary.ts +23 -3
- package/src/tools/shared/editTransaction.js +45 -0
- package/src/tools/shared/editTransaction.ts +39 -0
- package/src/tools/shared/fileActivity.js +10 -2
- package/src/tools/shared/fileActivity.ts +11 -2
- package/src/tools/shared/fileState.js +2 -0
- package/src/tools/shared/fileState.ts +4 -0
- package/src/tools/shared/readBudget.js +12 -0
- package/src/tools/shared/readBudget.ts +12 -0
- package/src/tools/toolPromptGuides.js +2 -2
- package/src/tools/toolPromptGuides.ts +2 -2
- package/src/tools/types.js +2 -6
- package/src/tools/types.ts +2 -6
- package/src/types.ts +9 -2
package/src/tools/read/read.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createReadStream } from 'node:fs';
|
|
2
2
|
import { open, readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { extname } from 'node:path';
|
|
4
|
-
import { createInterface } from 'node:readline';
|
|
5
4
|
import { runBoundedProcess } from '../../runtime/boundedProcess.js';
|
|
6
5
|
import { mkdir } from 'node:fs/promises';
|
|
7
6
|
import { join } from 'node:path';
|
|
@@ -18,6 +17,8 @@ import { pruneRuntimeOutputs, sessionRuntimeDir, } from '../../session/runtimeSt
|
|
|
18
17
|
import { detectImageMime, resolveLocalImage, } from '../../images/imageResolver.js';
|
|
19
18
|
const inputSchema = z.object({
|
|
20
19
|
file_path: filePathField('读取'),
|
|
20
|
+
column: z.number().int().positive().optional().describe('超长行续读:起始行内的 1-based UTF-16 字符位置;普通阅读省略。'),
|
|
21
|
+
max_chars: z.number().int().min(1000).max(1_000_000).optional().describe('期望正文字符预算,最多 100 万;Runtime 会按剩余上下文与并发读取预算收紧。'),
|
|
21
22
|
offset: z
|
|
22
23
|
.number()
|
|
23
24
|
.int()
|
|
@@ -56,7 +57,6 @@ const IMAGE_EXTENSIONS = new Set([
|
|
|
56
57
|
]);
|
|
57
58
|
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
|
|
58
59
|
const MAX_NOTEBOOK_BYTES = 32 * 1024 * 1024;
|
|
59
|
-
const SMALL_TEXT_BYTES = 16 * 1024 * 1024;
|
|
60
60
|
const description = `Reads local text files, images, and Jupyter notebooks.
|
|
61
61
|
|
|
62
62
|
Usage:
|
|
@@ -114,7 +114,7 @@ function renderNotebook(raw, offset, limit) {
|
|
|
114
114
|
partial: start > 0 || end < parsed.cells.length,
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
|
-
async function readStreamedText(filePath, offset, limit, signal) {
|
|
117
|
+
async function readStreamedText(filePath, offset, limit, signal, maxChars = DEFAULT_MAX_RESULT_SIZE_CHARS, column = 1) {
|
|
118
118
|
const handle = await open(filePath, 'r');
|
|
119
119
|
const prefix = Buffer.alloc(3);
|
|
120
120
|
const { bytesRead } = await handle.read(prefix, 0, 3, 0);
|
|
@@ -132,31 +132,81 @@ async function readStreamedText(filePath, offset, limit, signal) {
|
|
|
132
132
|
? 3
|
|
133
133
|
: 0,
|
|
134
134
|
});
|
|
135
|
-
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
136
135
|
const lines = [];
|
|
137
|
-
let line =
|
|
136
|
+
let line = 1;
|
|
138
137
|
let hasMore = false;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
138
|
+
let chars = 0;
|
|
139
|
+
let clippedLine;
|
|
140
|
+
let pending = '';
|
|
141
|
+
let skip = column - 1;
|
|
142
|
+
let sawData = false;
|
|
143
|
+
const append = (part, end) => {
|
|
144
|
+
if (line < offset) {
|
|
145
|
+
if (end)
|
|
146
|
+
line++;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (line === offset && skip) {
|
|
150
|
+
const consumed = Math.min(skip, part.length);
|
|
151
|
+
part = part.slice(consumed);
|
|
152
|
+
skip -= consumed;
|
|
153
|
+
}
|
|
154
|
+
if (lines.length >= limit) {
|
|
144
155
|
hasMore = true;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
const allowance = maxChars - chars - String(line).length - 1;
|
|
159
|
+
if (pending.length + part.length > allowance) {
|
|
160
|
+
if (!lines.length) {
|
|
161
|
+
const preview = (pending + part).slice(0, Math.max(0, allowance)).replace(/[\uD800-\uDBFF]$/, '');
|
|
162
|
+
lines.push(`${line}\t${preview}`);
|
|
163
|
+
clippedLine = line;
|
|
164
|
+
}
|
|
165
|
+
hasMore = true;
|
|
166
|
+
return false;
|
|
148
167
|
}
|
|
168
|
+
pending += part;
|
|
169
|
+
if (end) {
|
|
170
|
+
const numbered = `${line}\t${pending.replace(/\r$/, '')}`;
|
|
171
|
+
lines.push(numbered);
|
|
172
|
+
chars += numbered.length + 1;
|
|
173
|
+
pending = '';
|
|
174
|
+
line++;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
};
|
|
178
|
+
try {
|
|
179
|
+
outer: for await (const chunk of stream) {
|
|
180
|
+
sawData = true;
|
|
181
|
+
const text = String(chunk);
|
|
182
|
+
let start = 0;
|
|
183
|
+
for (let end = text.indexOf('\n'); end !== -1; end = text.indexOf('\n', start)) {
|
|
184
|
+
if (!append(text.slice(start, end), true))
|
|
185
|
+
break outer;
|
|
186
|
+
start = end + 1;
|
|
187
|
+
}
|
|
188
|
+
if (start < text.length && !append(text.slice(start), false))
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
if (!hasMore && sawData)
|
|
192
|
+
append('', true);
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
stream.destroy();
|
|
149
196
|
}
|
|
150
197
|
return {
|
|
151
198
|
numbered: lines.join('\n'),
|
|
152
|
-
totalKnown: line,
|
|
199
|
+
totalKnown: hasMore ? line : sawData ? line - 1 : 0,
|
|
153
200
|
selectedCount: lines.length,
|
|
154
201
|
hasMore,
|
|
202
|
+
clippedLine,
|
|
155
203
|
};
|
|
156
204
|
}
|
|
157
|
-
async function call(input, state, cwd, runtimeIdentity, signal) {
|
|
205
|
+
async function call(input, state, cwd, runtimeIdentity, signal, maxChars = DEFAULT_MAX_RESULT_SIZE_CHARS) {
|
|
158
206
|
if (signal?.aborted)
|
|
159
207
|
return { ok: false, error: 'aborted' };
|
|
208
|
+
if (maxChars < 1000)
|
|
209
|
+
return { ok: false, error: '本批读取上下文预算已用完;请先处理已有结果或压缩上下文,再继续读取。' };
|
|
160
210
|
const offset = input.offset ?? 1;
|
|
161
211
|
const limit = input.limit ?? MAX_LINES_TO_READ;
|
|
162
212
|
const resolved = resolveToolPath(input.file_path, cwd);
|
|
@@ -351,45 +401,39 @@ async function call(input, state, cwd, runtimeIdentity, signal) {
|
|
|
351
401
|
let hasMore;
|
|
352
402
|
let completeBytes;
|
|
353
403
|
let totalUnits;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
else {
|
|
369
|
-
const streamed = await readStreamedText(filePath, offset, limit, signal);
|
|
370
|
-
content = streamed.numbered;
|
|
371
|
-
totalLines = streamed.totalKnown;
|
|
372
|
-
selectedCount = streamed.selectedCount;
|
|
373
|
-
hasMore = streamed.hasMore;
|
|
374
|
-
// A streamed read only knows the complete total after consuming EOF.
|
|
375
|
-
totalUnits = hasMore ? undefined : totalLines;
|
|
376
|
-
}
|
|
404
|
+
let clippedLine;
|
|
405
|
+
const streamed = await readStreamedText(filePath, offset, limit, signal, maxChars, input.column);
|
|
406
|
+
content = streamed.numbered;
|
|
407
|
+
totalLines = streamed.totalKnown;
|
|
408
|
+
selectedCount = streamed.selectedCount;
|
|
409
|
+
hasMore = streamed.hasMore;
|
|
410
|
+
clippedLine = streamed.clippedLine;
|
|
411
|
+
totalUnits = hasMore ? undefined : totalLines;
|
|
412
|
+
if (!hasMore && offset === 1 && (input.column ?? 1) === 1)
|
|
413
|
+
completeBytes = await readFile(filePath, { signal });
|
|
414
|
+
const afterRead = await stat(filePath);
|
|
415
|
+
if (afterRead.mtimeMs !== info.mtimeMs || afterRead.size !== info.size || afterRead.ino !== info.ino)
|
|
416
|
+
return { ok: false, error: '读取期间文件发生变化,请重新读取;本次不登记读取覆盖范围。' };
|
|
377
417
|
if (!content)
|
|
378
418
|
content = totalLines === 0 ? '<file is empty>' : `<offset=${offset} 已超出文件末尾,共 ${totalLines} 行>`;
|
|
379
419
|
let truncated = false;
|
|
380
|
-
if (content.length >
|
|
381
|
-
|
|
420
|
+
if (content.length > maxChars || clippedLine !== undefined) {
|
|
421
|
+
const nextColumn = (input.column ?? 1) + Math.max(0, content.length - String(offset).length - 1);
|
|
422
|
+
content = `${content.slice(0, maxChars)}\n\n⚠️ 重要:Read 输出已截断;截断范围外仍有内容。超长行请提高 max_chars 或使用流式处理,不能跳过未显示内容。`;
|
|
423
|
+
if (clippedLine !== undefined)
|
|
424
|
+
content += `\n继续读取 offset=${clippedLine} column=${nextColumn}`;
|
|
382
425
|
truncated = true;
|
|
383
426
|
}
|
|
384
|
-
const partial = offset !== 1 || hasMore;
|
|
427
|
+
const partial = offset !== 1 || hasMore || (input.column ?? 1) > 1;
|
|
385
428
|
if (hasMore && !truncated)
|
|
386
|
-
content += `\n\n... (还有更多内容;继续使用 offset=${offset +
|
|
429
|
+
content += `\n\n... (还有更多内容;继续使用 offset=${offset + selectedCount})`;
|
|
387
430
|
const reads = recordRead(filePath, {
|
|
388
431
|
offset,
|
|
432
|
+
column: input.column,
|
|
389
433
|
limit,
|
|
390
434
|
partial,
|
|
391
435
|
truncated,
|
|
392
|
-
range: selectedCount > 0
|
|
436
|
+
range: selectedCount > 0 && (input.column ?? 1) === 1
|
|
393
437
|
? {
|
|
394
438
|
start: offset,
|
|
395
439
|
end: offset + selectedCount - 1,
|
|
@@ -412,7 +456,7 @@ async function call(input, state, cwd, runtimeIdentity, signal) {
|
|
|
412
456
|
path: filePath,
|
|
413
457
|
unit: 'line',
|
|
414
458
|
// A truncated response does not establish coverage of its full range.
|
|
415
|
-
...(!truncated && selectedCount > 0
|
|
459
|
+
...(!truncated && selectedCount > 0 && (input.column ?? 1) === 1
|
|
416
460
|
? { start: offset, end: offset + selectedCount - 1 }
|
|
417
461
|
: {}),
|
|
418
462
|
truncated,
|
|
@@ -544,8 +588,8 @@ export const readTool = {
|
|
|
544
588
|
parameters: toToolParameters(inputSchema),
|
|
545
589
|
isReadOnly: true,
|
|
546
590
|
isConcurrencySafe: true,
|
|
547
|
-
maxResultSizeChars:
|
|
591
|
+
maxResultSizeChars: 1_010_000,
|
|
548
592
|
interruptBehavior: () => 'cancel',
|
|
549
|
-
call: (input, signal) => call(input, undefined, undefined, undefined, signal),
|
|
550
|
-
callWithContext: (input, signal, context) => call(input, context.sessionState?.fileReads, context.cwd, context, signal),
|
|
593
|
+
call: (input, signal) => call(input, undefined, undefined, undefined, signal, input.max_chars ?? DEFAULT_MAX_RESULT_SIZE_CHARS),
|
|
594
|
+
callWithContext: (input, signal, context) => call(input, context.sessionState?.fileReads, context.cwd, context, signal, context.reserveReadChars?.(input.max_chars) ?? input.max_chars ?? DEFAULT_MAX_RESULT_SIZE_CHARS),
|
|
551
595
|
};
|
package/src/tools/read/read.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createReadStream } from 'node:fs';
|
|
2
2
|
import { open, readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { extname } from 'node:path';
|
|
4
|
-
import { createInterface } from 'node:readline';
|
|
5
4
|
import { runBoundedProcess } from '../../runtime/boundedProcess.js';
|
|
6
5
|
import { mkdir } from 'node:fs/promises';
|
|
7
6
|
import { join } from 'node:path';
|
|
@@ -41,6 +40,8 @@ import {
|
|
|
41
40
|
|
|
42
41
|
const inputSchema = z.object({
|
|
43
42
|
file_path: filePathField('读取'),
|
|
43
|
+
column: z.number().int().positive().optional().describe('超长行续读:起始行内的 1-based UTF-16 字符位置;普通阅读省略。'),
|
|
44
|
+
max_chars: z.number().int().min(1000).max(1_000_000).optional().describe('期望正文字符预算,最多 100 万;Runtime 会按剩余上下文与并发读取预算收紧。'),
|
|
44
45
|
offset: z
|
|
45
46
|
.number()
|
|
46
47
|
.int()
|
|
@@ -83,7 +84,6 @@ const IMAGE_EXTENSIONS = new Set([
|
|
|
83
84
|
]);
|
|
84
85
|
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
|
|
85
86
|
const MAX_NOTEBOOK_BYTES = 32 * 1024 * 1024;
|
|
86
|
-
const SMALL_TEXT_BYTES = 16 * 1024 * 1024;
|
|
87
87
|
|
|
88
88
|
const description = `Reads local text files, images, and Jupyter notebooks.
|
|
89
89
|
|
|
@@ -164,11 +164,14 @@ async function readStreamedText(
|
|
|
164
164
|
offset: number,
|
|
165
165
|
limit: number,
|
|
166
166
|
signal?: AbortSignal,
|
|
167
|
+
maxChars = DEFAULT_MAX_RESULT_SIZE_CHARS,
|
|
168
|
+
column = 1,
|
|
167
169
|
): Promise<{
|
|
168
170
|
numbered: string;
|
|
169
171
|
totalKnown: number;
|
|
170
172
|
selectedCount: number;
|
|
171
173
|
hasMore: boolean;
|
|
174
|
+
clippedLine?: number;
|
|
172
175
|
}> {
|
|
173
176
|
const handle = await open(filePath, 'r');
|
|
174
177
|
const prefix = Buffer.alloc(3);
|
|
@@ -187,25 +190,57 @@ async function readStreamedText(
|
|
|
187
190
|
? 3
|
|
188
191
|
: 0,
|
|
189
192
|
});
|
|
190
|
-
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
191
193
|
const lines: string[] = [];
|
|
192
|
-
let line =
|
|
194
|
+
let line = 1;
|
|
193
195
|
let hasMore = false;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
196
|
+
let chars = 0;
|
|
197
|
+
let clippedLine: number | undefined;
|
|
198
|
+
let pending = '';
|
|
199
|
+
let skip = column - 1;
|
|
200
|
+
let sawData = false;
|
|
201
|
+
const append = (part: string, end: boolean): boolean => {
|
|
202
|
+
if (line < offset) { if (end) line++; return true; }
|
|
203
|
+
if (line === offset && skip) {
|
|
204
|
+
const consumed = Math.min(skip, part.length);
|
|
205
|
+
part = part.slice(consumed); skip -= consumed;
|
|
202
206
|
}
|
|
203
|
-
|
|
207
|
+
if (lines.length >= limit) { hasMore = true; return false; }
|
|
208
|
+
const allowance = maxChars - chars - String(line).length - 1;
|
|
209
|
+
if (pending.length + part.length > allowance) {
|
|
210
|
+
if (!lines.length) {
|
|
211
|
+
const preview = (pending + part).slice(0, Math.max(0, allowance)).replace(/[\uD800-\uDBFF]$/, '');
|
|
212
|
+
lines.push(`${line}\t${preview}`);
|
|
213
|
+
clippedLine = line;
|
|
214
|
+
}
|
|
215
|
+
hasMore = true; return false;
|
|
216
|
+
}
|
|
217
|
+
pending += part;
|
|
218
|
+
if (end) {
|
|
219
|
+
const numbered = `${line}\t${pending.replace(/\r$/, '')}`;
|
|
220
|
+
lines.push(numbered); chars += numbered.length + 1;
|
|
221
|
+
pending = ''; line++;
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
outer: for await (const chunk of stream) {
|
|
227
|
+
sawData = true;
|
|
228
|
+
const text = String(chunk);
|
|
229
|
+
let start = 0;
|
|
230
|
+
for (let end = text.indexOf('\n'); end !== -1; end = text.indexOf('\n', start)) {
|
|
231
|
+
if (!append(text.slice(start, end), true)) break outer;
|
|
232
|
+
start = end + 1;
|
|
233
|
+
}
|
|
234
|
+
if (start < text.length && !append(text.slice(start), false)) break;
|
|
235
|
+
}
|
|
236
|
+
if (!hasMore && sawData) append('', true);
|
|
237
|
+
} finally { stream.destroy(); }
|
|
204
238
|
return {
|
|
205
239
|
numbered: lines.join('\n'),
|
|
206
|
-
totalKnown: line,
|
|
240
|
+
totalKnown: hasMore ? line : sawData ? line - 1 : 0,
|
|
207
241
|
selectedCount: lines.length,
|
|
208
242
|
hasMore,
|
|
243
|
+
clippedLine,
|
|
209
244
|
};
|
|
210
245
|
}
|
|
211
246
|
|
|
@@ -215,8 +250,10 @@ async function call(
|
|
|
215
250
|
cwd?: string,
|
|
216
251
|
runtimeIdentity?: RuntimeIdentity,
|
|
217
252
|
signal?: AbortSignal,
|
|
253
|
+
maxChars = DEFAULT_MAX_RESULT_SIZE_CHARS,
|
|
218
254
|
): Promise<ToolResult> {
|
|
219
255
|
if (signal?.aborted) return { ok: false, error: 'aborted' };
|
|
256
|
+
if (maxChars < 1000) return { ok: false, error: '本批读取上下文预算已用完;请先处理已有结果或压缩上下文,再继续读取。' };
|
|
220
257
|
const offset = input.offset ?? 1;
|
|
221
258
|
const limit = input.limit ?? MAX_LINES_TO_READ;
|
|
222
259
|
const resolved = resolveToolPath(input.file_path, cwd);
|
|
@@ -454,47 +491,40 @@ async function call(
|
|
|
454
491
|
let hasMore: boolean;
|
|
455
492
|
let completeBytes: Buffer | undefined;
|
|
456
493
|
let totalUnits: number | undefined;
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
totalUnits = totalLines;
|
|
471
|
-
} else {
|
|
472
|
-
const streamed = await readStreamedText(filePath, offset, limit, signal);
|
|
473
|
-
content = streamed.numbered;
|
|
474
|
-
totalLines = streamed.totalKnown;
|
|
475
|
-
selectedCount = streamed.selectedCount;
|
|
476
|
-
hasMore = streamed.hasMore;
|
|
477
|
-
// A streamed read only knows the complete total after consuming EOF.
|
|
478
|
-
totalUnits = hasMore ? undefined : totalLines;
|
|
479
|
-
}
|
|
494
|
+
let clippedLine: number | undefined;
|
|
495
|
+
const streamed = await readStreamedText(filePath, offset, limit, signal, maxChars, input.column);
|
|
496
|
+
content = streamed.numbered;
|
|
497
|
+
totalLines = streamed.totalKnown;
|
|
498
|
+
selectedCount = streamed.selectedCount;
|
|
499
|
+
hasMore = streamed.hasMore;
|
|
500
|
+
clippedLine = streamed.clippedLine;
|
|
501
|
+
totalUnits = hasMore ? undefined : totalLines;
|
|
502
|
+
if (!hasMore && offset === 1 && (input.column ?? 1) === 1)
|
|
503
|
+
completeBytes = await readFile(filePath, { signal });
|
|
504
|
+
const afterRead = await stat(filePath);
|
|
505
|
+
if (afterRead.mtimeMs !== info.mtimeMs || afterRead.size !== info.size || afterRead.ino !== info.ino)
|
|
506
|
+
return { ok: false, error: '读取期间文件发生变化,请重新读取;本次不登记读取覆盖范围。' };
|
|
480
507
|
if (!content) content = totalLines === 0 ? '<file is empty>' : `<offset=${offset} 已超出文件末尾,共 ${totalLines} 行>`;
|
|
481
508
|
let truncated = false;
|
|
482
|
-
if (content.length >
|
|
483
|
-
|
|
509
|
+
if (content.length > maxChars || clippedLine !== undefined) {
|
|
510
|
+
const nextColumn = (input.column ?? 1) + Math.max(0, content.length - String(offset).length - 1);
|
|
511
|
+
content = `${content.slice(0, maxChars)}\n\n⚠️ 重要:Read 输出已截断;截断范围外仍有内容。超长行请提高 max_chars 或使用流式处理,不能跳过未显示内容。`;
|
|
512
|
+
if (clippedLine !== undefined) content += `\n继续读取 offset=${clippedLine} column=${nextColumn}`;
|
|
484
513
|
truncated = true;
|
|
485
514
|
}
|
|
486
|
-
const partial = offset !== 1 || hasMore;
|
|
515
|
+
const partial = offset !== 1 || hasMore || (input.column ?? 1) > 1;
|
|
487
516
|
if (hasMore && !truncated)
|
|
488
|
-
content += `\n\n... (还有更多内容;继续使用 offset=${offset +
|
|
517
|
+
content += `\n\n... (还有更多内容;继续使用 offset=${offset + selectedCount})`;
|
|
489
518
|
const reads = recordRead(
|
|
490
519
|
filePath,
|
|
491
520
|
{
|
|
492
521
|
offset,
|
|
522
|
+
column: input.column,
|
|
493
523
|
limit,
|
|
494
524
|
partial,
|
|
495
525
|
truncated,
|
|
496
526
|
range:
|
|
497
|
-
selectedCount > 0
|
|
527
|
+
selectedCount > 0 && (input.column ?? 1) === 1
|
|
498
528
|
? {
|
|
499
529
|
start: offset,
|
|
500
530
|
end: offset + selectedCount - 1,
|
|
@@ -519,7 +549,7 @@ async function call(
|
|
|
519
549
|
path: filePath,
|
|
520
550
|
unit: 'line',
|
|
521
551
|
// A truncated response does not establish coverage of its full range.
|
|
522
|
-
...(!truncated && selectedCount > 0
|
|
552
|
+
...(!truncated && selectedCount > 0 && (input.column ?? 1) === 1
|
|
523
553
|
? { start: offset, end: offset + selectedCount - 1 }
|
|
524
554
|
: {}),
|
|
525
555
|
truncated,
|
|
@@ -687,9 +717,10 @@ export const readTool: Tool<ReadInput> = {
|
|
|
687
717
|
parameters: toToolParameters(inputSchema),
|
|
688
718
|
isReadOnly: true,
|
|
689
719
|
isConcurrencySafe: true,
|
|
690
|
-
maxResultSizeChars:
|
|
720
|
+
maxResultSizeChars: 1_010_000,
|
|
691
721
|
interruptBehavior: () => 'cancel',
|
|
692
|
-
call: (input, signal) => call(input, undefined, undefined, undefined, signal),
|
|
722
|
+
call: (input, signal) => call(input, undefined, undefined, undefined, signal, input.max_chars ?? DEFAULT_MAX_RESULT_SIZE_CHARS),
|
|
693
723
|
callWithContext: (input, signal, context: ToolCallContext) =>
|
|
694
|
-
call(input, context.sessionState?.fileReads, context.cwd, context, signal
|
|
724
|
+
call(input, context.sessionState?.fileReads, context.cwd, context, signal,
|
|
725
|
+
context.reserveReadChars?.(input.max_chars) ?? input.max_chars ?? DEFAULT_MAX_RESULT_SIZE_CHARS),
|
|
695
726
|
};
|
|
@@ -12,16 +12,38 @@ export function buildPatchSummary(before, after, label) {
|
|
|
12
12
|
const totalBytes = Buffer.byteLength(before) + Buffer.byteLength(after);
|
|
13
13
|
const totalLines = (before.match(/\n/g)?.length ?? 0) + (after.match(/\n/g)?.length ?? 0) + 2;
|
|
14
14
|
if (totalBytes > MAX_BYTES || totalLines > MAX_LINES) {
|
|
15
|
+
// Strip identical outer lines before expensive diff work. A large file with
|
|
16
|
+
// a small edit still gets a real preview with original line coordinates.
|
|
17
|
+
let prefix = 0;
|
|
18
|
+
while (prefix < Math.min(before.length, after.length) && before[prefix] === after[prefix])
|
|
19
|
+
prefix++;
|
|
20
|
+
const start = before.lastIndexOf('\n', Math.max(0, prefix - 1)) + 1;
|
|
21
|
+
let suffix = 0;
|
|
22
|
+
while (suffix < Math.min(before.length, after.length) - start &&
|
|
23
|
+
before[before.length - suffix - 1] === after[after.length - suffix - 1])
|
|
24
|
+
suffix++;
|
|
25
|
+
const tail = before.indexOf('\n', before.length - suffix);
|
|
26
|
+
const suffixLength = tail < 0 ? 0 : before.length - tail - 1;
|
|
27
|
+
const oldWindow = before.slice(start, before.length - suffixLength);
|
|
28
|
+
const newWindow = after.slice(start, after.length - suffixLength);
|
|
29
|
+
if (Buffer.byteLength(oldWindow) + Buffer.byteLength(newWindow) <= MAX_BYTES &&
|
|
30
|
+
(oldWindow.match(/\n/g)?.length ?? 0) + (newWindow.match(/\n/g)?.length ?? 0) + 2 <= MAX_LINES) {
|
|
31
|
+
const local = buildPatchSummary(oldWindow, newWindow, label);
|
|
32
|
+
const shift = (before.slice(0, start).match(/\n/g)?.length ?? 0);
|
|
33
|
+
return { ...local, beforeHash, afterHash,
|
|
34
|
+
unifiedDiff: local.unifiedDiff?.replace(/@@ -(\d+)(,\d+)? \+(\d+)(,\d+)? @@/g, (_, a, ac = '', b, bc = '') => `@@ -${Number(a) + shift}${ac} +${Number(b) + shift}${bc} @@`) };
|
|
35
|
+
}
|
|
15
36
|
return {
|
|
16
37
|
beforeHash,
|
|
17
38
|
afterHash,
|
|
18
|
-
additions: 0,
|
|
19
|
-
deletions: 0,
|
|
20
39
|
truncated: true,
|
|
21
40
|
omittedReason: 'too_large',
|
|
22
41
|
};
|
|
23
42
|
}
|
|
24
|
-
const changes = diffLines(before, after);
|
|
43
|
+
const changes = diffLines(before, after, { timeout: 500, maxEditLength: 10_000 });
|
|
44
|
+
if (!changes)
|
|
45
|
+
return { beforeHash, afterHash,
|
|
46
|
+
truncated: true, omittedReason: 'too_large' };
|
|
25
47
|
const additions = changes
|
|
26
48
|
.filter((change) => change.added)
|
|
27
49
|
.reduce((sum, change) => sum + (change.count ?? 0), 0);
|
|
@@ -22,17 +22,37 @@ export function buildPatchSummary(
|
|
|
22
22
|
const totalLines =
|
|
23
23
|
(before.match(/\n/g)?.length ?? 0) + (after.match(/\n/g)?.length ?? 0) + 2;
|
|
24
24
|
if (totalBytes > MAX_BYTES || totalLines > MAX_LINES) {
|
|
25
|
+
// Strip identical outer lines before expensive diff work. A large file with
|
|
26
|
+
// a small edit still gets a real preview with original line coordinates.
|
|
27
|
+
let prefix = 0;
|
|
28
|
+
while (prefix < Math.min(before.length, after.length) && before[prefix] === after[prefix]) prefix++;
|
|
29
|
+
const start = before.lastIndexOf('\n', Math.max(0, prefix - 1)) + 1;
|
|
30
|
+
let suffix = 0;
|
|
31
|
+
while (suffix < Math.min(before.length, after.length) - start &&
|
|
32
|
+
before[before.length - suffix - 1] === after[after.length - suffix - 1]) suffix++;
|
|
33
|
+
const tail = before.indexOf('\n', before.length - suffix);
|
|
34
|
+
const suffixLength = tail < 0 ? 0 : before.length - tail - 1;
|
|
35
|
+
const oldWindow = before.slice(start, before.length - suffixLength);
|
|
36
|
+
const newWindow = after.slice(start, after.length - suffixLength);
|
|
37
|
+
if (Buffer.byteLength(oldWindow) + Buffer.byteLength(newWindow) <= MAX_BYTES &&
|
|
38
|
+
(oldWindow.match(/\n/g)?.length ?? 0) + (newWindow.match(/\n/g)?.length ?? 0) + 2 <= MAX_LINES) {
|
|
39
|
+
const local = buildPatchSummary(oldWindow, newWindow, label);
|
|
40
|
+
const shift = (before.slice(0, start).match(/\n/g)?.length ?? 0);
|
|
41
|
+
return { ...local, beforeHash, afterHash,
|
|
42
|
+
unifiedDiff: local.unifiedDiff?.replace(/@@ -(\d+)(,\d+)? \+(\d+)(,\d+)? @@/g,
|
|
43
|
+
(_, a, ac = '', b, bc = '') => `@@ -${Number(a) + shift}${ac} +${Number(b) + shift}${bc} @@`) };
|
|
44
|
+
}
|
|
25
45
|
return {
|
|
26
46
|
beforeHash,
|
|
27
47
|
afterHash,
|
|
28
|
-
additions: 0,
|
|
29
|
-
deletions: 0,
|
|
30
48
|
truncated: true,
|
|
31
49
|
omittedReason: 'too_large',
|
|
32
50
|
};
|
|
33
51
|
}
|
|
34
52
|
|
|
35
|
-
const changes = diffLines(before, after);
|
|
53
|
+
const changes = diffLines(before, after, { timeout: 500, maxEditLength: 10_000 });
|
|
54
|
+
if (!changes) return { beforeHash, afterHash,
|
|
55
|
+
truncated: true, omittedReason: 'too_large' };
|
|
36
56
|
const additions = changes
|
|
37
57
|
.filter((change) => change.added)
|
|
38
58
|
.reduce((sum, change) => sum + (change.count ?? 0), 0);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile, stat, realpath } from 'node:fs/promises';
|
|
2
|
+
import { atomicWriteFile, encodeTextFile, readTextFile } from './fileUtils.js';
|
|
3
|
+
import { hashFileBytes } from './fileState.js';
|
|
4
|
+
// Bound the in-memory replacement engine independently of model context size.
|
|
5
|
+
export const MAX_EDIT_BYTES = 128 * 1024 * 1024;
|
|
6
|
+
const commits = new Map();
|
|
7
|
+
export async function readEditSnapshot(path) {
|
|
8
|
+
if ((await stat(path)).size > MAX_EDIT_BYTES)
|
|
9
|
+
throw new Error('文件超过 128 MiB 内存编辑边界;请使用流式转换写入临时文件并校验,不要用 Write 全文覆盖。');
|
|
10
|
+
const snapshot = await readTextFile(path);
|
|
11
|
+
if (snapshot.bytes.length > MAX_EDIT_BYTES)
|
|
12
|
+
throw new Error('读取期间文件超过编辑边界');
|
|
13
|
+
return snapshot;
|
|
14
|
+
}
|
|
15
|
+
export async function commitEdit(path, snapshot, content) {
|
|
16
|
+
const key = await realpath(path);
|
|
17
|
+
const previous = commits.get(key) ?? Promise.resolve();
|
|
18
|
+
let release;
|
|
19
|
+
const current = new Promise(resolve => { release = resolve; });
|
|
20
|
+
commits.set(key, current);
|
|
21
|
+
await previous;
|
|
22
|
+
try {
|
|
23
|
+
return await commitSnapshot(key, snapshot, content);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
release();
|
|
27
|
+
if (commits.get(key) === current)
|
|
28
|
+
commits.delete(key);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function commitSnapshot(path, snapshot, content) {
|
|
32
|
+
const bytes = encodeTextFile(content, snapshot.encoding);
|
|
33
|
+
if (bytes.length > MAX_EDIT_BYTES)
|
|
34
|
+
throw new Error('编辑结果超过 128 MiB 内存编辑边界');
|
|
35
|
+
const beforeHash = hashFileBytes(snapshot.bytes);
|
|
36
|
+
if (hashFileBytes(await readFile(path)) !== beforeHash)
|
|
37
|
+
throw new Error('编辑期间文件已被其他操作修改,未提交;请重新读取后再编辑。');
|
|
38
|
+
// Optimistic conflict check, not an OS-wide compare-and-swap guarantee.
|
|
39
|
+
if (!bytes.equals(snapshot.bytes))
|
|
40
|
+
await atomicWriteFile(path, bytes);
|
|
41
|
+
const afterHash = hashFileBytes(bytes);
|
|
42
|
+
if (hashFileBytes(await readFile(path)) !== afterHash)
|
|
43
|
+
throw new Error('提交后文件内容与预期不一致(可能被外部进程改写);请读取核验,不要盲目重复替换。');
|
|
44
|
+
return { beforeHash, afterHash };
|
|
45
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFile, stat, realpath } from 'node:fs/promises';
|
|
2
|
+
import { atomicWriteFile, encodeTextFile, readTextFile } from './fileUtils.js';
|
|
3
|
+
import { hashFileBytes } from './fileState.js';
|
|
4
|
+
|
|
5
|
+
// Bound the in-memory replacement engine independently of model context size.
|
|
6
|
+
export const MAX_EDIT_BYTES = 128 * 1024 * 1024;
|
|
7
|
+
const commits = new Map<string, Promise<void>>();
|
|
8
|
+
export async function readEditSnapshot(path: string) {
|
|
9
|
+
if ((await stat(path)).size > MAX_EDIT_BYTES)
|
|
10
|
+
throw new Error('文件超过 128 MiB 内存编辑边界;请使用流式转换写入临时文件并校验,不要用 Write 全文覆盖。');
|
|
11
|
+
const snapshot = await readTextFile(path);
|
|
12
|
+
if (snapshot.bytes.length > MAX_EDIT_BYTES) throw new Error('读取期间文件超过编辑边界');
|
|
13
|
+
return snapshot;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function commitEdit(path: string, snapshot: Awaited<ReturnType<typeof readEditSnapshot>>, content: string) {
|
|
17
|
+
const key = await realpath(path);
|
|
18
|
+
const previous = commits.get(key) ?? Promise.resolve();
|
|
19
|
+
let release!: () => void;
|
|
20
|
+
const current = new Promise<void>(resolve => { release = resolve; });
|
|
21
|
+
commits.set(key, current);
|
|
22
|
+
await previous;
|
|
23
|
+
try { return await commitSnapshot(key, snapshot, content); }
|
|
24
|
+
finally { release(); if (commits.get(key) === current) commits.delete(key); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function commitSnapshot(path: string, snapshot: Awaited<ReturnType<typeof readEditSnapshot>>, content: string) {
|
|
28
|
+
const bytes = encodeTextFile(content, snapshot.encoding);
|
|
29
|
+
if (bytes.length > MAX_EDIT_BYTES) throw new Error('编辑结果超过 128 MiB 内存编辑边界');
|
|
30
|
+
const beforeHash = hashFileBytes(snapshot.bytes);
|
|
31
|
+
if (hashFileBytes(await readFile(path)) !== beforeHash)
|
|
32
|
+
throw new Error('编辑期间文件已被其他操作修改,未提交;请重新读取后再编辑。');
|
|
33
|
+
// Optimistic conflict check, not an OS-wide compare-and-swap guarantee.
|
|
34
|
+
if (!bytes.equals(snapshot.bytes)) await atomicWriteFile(path, bytes);
|
|
35
|
+
const afterHash = hashFileBytes(bytes);
|
|
36
|
+
if (hashFileBytes(await readFile(path)) !== afterHash)
|
|
37
|
+
throw new Error('提交后文件内容与预期不一致(可能被外部进程改写);请读取核验,不要盲目重复替换。');
|
|
38
|
+
return { beforeHash, afterHash };
|
|
39
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
3
4
|
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
5
|
import { getWorkingDir } from '../../bootstrap/workingDir.js';
|
|
5
6
|
const MIME_BY_EXT = {
|
|
@@ -32,7 +33,14 @@ export async function fileActivityMetadata(filePath, activity, verification = 'n
|
|
|
32
33
|
let sha256;
|
|
33
34
|
if (activity !== 'deleted') {
|
|
34
35
|
try {
|
|
35
|
-
|
|
36
|
+
// A range Read must not secretly load/hash a gigabyte for UI metadata.
|
|
37
|
+
// Hash is optional for sources; committed artifacts use bounded buffers.
|
|
38
|
+
if (activity !== 'read' || (await stat(candidate)).size <= 8 * 1024 * 1024) {
|
|
39
|
+
const hash = createHash('sha256');
|
|
40
|
+
for await (const chunk of createReadStream(candidate))
|
|
41
|
+
hash.update(chunk);
|
|
42
|
+
sha256 = hash.digest('hex');
|
|
43
|
+
}
|
|
36
44
|
}
|
|
37
45
|
catch { /* The file activity remains useful even when hashing fails. */ }
|
|
38
46
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
3
4
|
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
5
|
|
|
5
6
|
import { getWorkingDir } from '../../bootstrap/workingDir.js';
|
|
@@ -48,7 +49,15 @@ export async function fileActivityMetadata(
|
|
|
48
49
|
|
|
49
50
|
let sha256: string | undefined;
|
|
50
51
|
if (activity !== 'deleted') {
|
|
51
|
-
try {
|
|
52
|
+
try {
|
|
53
|
+
// A range Read must not secretly load/hash a gigabyte for UI metadata.
|
|
54
|
+
// Hash is optional for sources; committed artifacts use bounded buffers.
|
|
55
|
+
if (activity !== 'read' || (await stat(candidate)).size <= 8 * 1024 * 1024) {
|
|
56
|
+
const hash = createHash('sha256');
|
|
57
|
+
for await (const chunk of createReadStream(candidate)) hash.update(chunk);
|
|
58
|
+
sha256 = hash.digest('hex');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
52
61
|
catch { /* The file activity remains useful even when hashing fails. */ }
|
|
53
62
|
}
|
|
54
63
|
return {
|