minimal-agent 0.18.9 → 0.18.10
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/loop.js +2 -0
- package/src/loop.ts +2 -0
- package/src/tools/applypatch/applyPatch.js +1 -1
- package/src/tools/applypatch/applyPatch.ts +1 -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 +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimal-agent",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.10",
|
|
4
4
|
"description": "Bun-first 的本地 Agent 内核,支持工具、插件、工作流、上下文压缩、长期记忆与 OpenAI 兼容接口;Tauri 桌面宿主和 Node.js npm 发布可复用同一内核",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Bill Wang <leiwang0359@gmail.com>",
|
package/src/loop.js
CHANGED
|
@@ -50,6 +50,7 @@ import { assembleContextBundle, contextFragment, resizeContextBundle } from './c
|
|
|
50
50
|
import { chat as defaultChat } from './llm/client.js';
|
|
51
51
|
import { buildUserContent } from './multimodal.js';
|
|
52
52
|
import { ALL_TOOLS, executeTool as defaultExecuteTool, getToolByName, getToolGateDetails, } from './tools/index.js';
|
|
53
|
+
import { createReadBudget } from './tools/shared/readBudget.js';
|
|
53
54
|
import { configService } from './config/configService.js';
|
|
54
55
|
import { capabilityForToolName } from './tools/capabilities.js';
|
|
55
56
|
import { createEffectiveToolSet } from './tools/effectiveToolSet.js';
|
|
@@ -713,6 +714,7 @@ export async function* runQuery(userInput, options) {
|
|
|
713
714
|
message: `当前模型窗口压力过高,本次请求临时省略工具 Schema:${emergencyFit.removedNames.join(', ')};下一轮会重新评估并恢复。`,
|
|
714
715
|
};
|
|
715
716
|
}
|
|
717
|
+
toolCallContext.reserveReadChars = createReadBudget(finalRequestBudget.inputLimit - finalRequestBudget.totalInputTokens);
|
|
716
718
|
if (finalRequestBudget.overBudget) {
|
|
717
719
|
yield {
|
|
718
720
|
type: 'error',
|
package/src/loop.ts
CHANGED
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
getToolGateDetails,
|
|
73
73
|
} from './tools/index.js';
|
|
74
74
|
import type { ToolGate } from './toolGate.js';
|
|
75
|
+
import { createReadBudget } from './tools/shared/readBudget.js';
|
|
75
76
|
import type {
|
|
76
77
|
DeliverableManifest,
|
|
77
78
|
ImageDetail,
|
|
@@ -938,6 +939,7 @@ export async function* runQuery(
|
|
|
938
939
|
message: `当前模型窗口压力过高,本次请求临时省略工具 Schema:${emergencyFit.removedNames.join(', ')};下一轮会重新评估并恢复。`,
|
|
939
940
|
};
|
|
940
941
|
}
|
|
942
|
+
toolCallContext.reserveReadChars = createReadBudget(finalRequestBudget.inputLimit - finalRequestBudget.totalInputTokens);
|
|
941
943
|
if (finalRequestBudget.overBudget) {
|
|
942
944
|
yield {
|
|
943
945
|
type: 'error',
|
|
@@ -341,7 +341,7 @@ async function call(input, context) {
|
|
|
341
341
|
contentHash: hashFileBytes(encodeTextFile(file.after, file.encoding)),
|
|
342
342
|
}, context?.sessionState?.fileReads);
|
|
343
343
|
emitFileMutation(file.path);
|
|
344
|
-
summaries.push(`${file.activity}: ${file.label} (
|
|
344
|
+
summaries.push(`${file.activity}: ${file.label} (${patch.additions === undefined ? 'diff 未统计' : `+${patch.additions} -${patch.deletions}`})`);
|
|
345
345
|
}
|
|
346
346
|
return {
|
|
347
347
|
ok: true,
|
|
@@ -403,7 +403,7 @@ async function call(
|
|
|
403
403
|
);
|
|
404
404
|
emitFileMutation(file.path);
|
|
405
405
|
summaries.push(
|
|
406
|
-
`${file.activity}: ${file.label} (
|
|
406
|
+
`${file.activity}: ${file.label} (${patch.additions === undefined ? 'diff 未统计' : `+${patch.additions} -${patch.deletions}`})`,
|
|
407
407
|
);
|
|
408
408
|
}
|
|
409
409
|
return {
|
package/src/tools/edit/edit.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* v0.2 增强:
|
|
9
9
|
* - 路径安全校验(null byte / UNC 路径)
|
|
10
|
-
* -
|
|
10
|
+
* - 共享有界编辑事务与提交后回读核验
|
|
11
11
|
* - old_string 找不到时提供模糊匹配提示(最常见模型错误)
|
|
12
12
|
* ============================================================
|
|
13
13
|
*/
|
|
@@ -15,14 +15,13 @@ import { existsSync } from 'node:fs';
|
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
|
|
17
17
|
import { toToolParameters } from '../../utils/zodToJson.js';
|
|
18
|
-
import { resolveToolPath, findActualString, preserveQuoteStyle, detectLineEndingsForString, applyLineEnding, countOccurrences, splitReplaceAll, tryAutoSnapshot, atomicWriteFile, encodeTextFile,
|
|
18
|
+
import { resolveToolPath, findActualString, preserveQuoteStyle, detectLineEndingsForString, applyLineEnding, countOccurrences, splitReplaceAll, tryAutoSnapshot, atomicWriteFile, encodeTextFile, } from '../shared/fileUtils.js';
|
|
19
19
|
import { filePathField } from '../shared/schemas.js';
|
|
20
20
|
import { assertFresh, hashFileBytes, recordRead, wasTruncated } from '../shared/fileState.js';
|
|
21
21
|
import { fileActivityMetadata } from '../shared/fileActivity.js';
|
|
22
22
|
import { emitFileMutation } from '../shared/fileMutationBus.js';
|
|
23
23
|
import { buildPatchSummary } from '../shared/diffSummary.js';
|
|
24
|
-
|
|
25
|
-
const MAX_EDIT_FILE_SIZE_BYTES = 1024 * 1024 * 1024;
|
|
24
|
+
import { readEditSnapshot, commitEdit } from '../shared/editTransaction.js';
|
|
26
25
|
// ---------------- 1. Zod 输入 schema ----------------
|
|
27
26
|
const inputSchema = z.object({
|
|
28
27
|
file_path: filePathField('编辑'),
|
|
@@ -43,7 +42,7 @@ const description = `Performs exact string replacements in files.
|
|
|
43
42
|
Usage:
|
|
44
43
|
- You MUST use your \`Read\` tool to read the current content of the file BEFORE calling Edit.
|
|
45
44
|
Memory is unreliable — if you think you "remember" how the code looks, you are probably wrong.
|
|
46
|
-
|
|
45
|
+
Read the relevant sections; reuse unchanged read evidence instead of repeatedly reading the whole file.
|
|
47
46
|
- To create a new file, pass an empty \`old_string\` and the desired contents as \`new_string\`. When creating a new file (path does not yet exist), you may call Edit directly — no prior Read is needed.
|
|
48
47
|
- If the file already exists and you pass an empty \`old_string\`, the edit is rejected with a clear error. To replace the entire content of an existing file, use the Write tool instead.
|
|
49
48
|
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
|
@@ -116,9 +115,11 @@ async function call(input, state, cwd) {
|
|
|
116
115
|
};
|
|
117
116
|
}
|
|
118
117
|
let original;
|
|
118
|
+
let editSnapshot;
|
|
119
119
|
let sourceEncoding;
|
|
120
120
|
try {
|
|
121
|
-
const snapshot = await
|
|
121
|
+
const snapshot = await readEditSnapshot(filePath);
|
|
122
|
+
editSnapshot = snapshot;
|
|
122
123
|
original = snapshot.content;
|
|
123
124
|
sourceEncoding = snapshot.encoding;
|
|
124
125
|
}
|
|
@@ -127,14 +128,6 @@ async function call(input, state, cwd) {
|
|
|
127
128
|
}
|
|
128
129
|
// 5d-1. 记录原文件行尾符风格(写入时保持一致)
|
|
129
130
|
const originalLineEnding = detectLineEndingsForString(original);
|
|
130
|
-
// 5d. 文件大小上限检查
|
|
131
|
-
const fileSize = Buffer.byteLength(original, 'utf8');
|
|
132
|
-
if (fileSize > MAX_EDIT_FILE_SIZE_BYTES) {
|
|
133
|
-
return {
|
|
134
|
-
ok: false,
|
|
135
|
-
error: `文件过大(${fileSize} 字节 > ${MAX_EDIT_FILE_SIZE_BYTES} 字节 ≈ 1GB)。Edit 不适合操作超大文件,请用 Write 工具。`,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
131
|
// 唯性检查
|
|
139
132
|
if (old_string === '') {
|
|
140
133
|
return {
|
|
@@ -176,7 +169,7 @@ async function call(input, state, cwd) {
|
|
|
176
169
|
// 5f. 保持原文件行尾符风格
|
|
177
170
|
const normalizedReplaced = applyLineEnding(replaced, originalLineEnding);
|
|
178
171
|
try {
|
|
179
|
-
await
|
|
172
|
+
await commitEdit(filePath, editSnapshot, normalizedReplaced);
|
|
180
173
|
recordRead(filePath, {
|
|
181
174
|
contentHash: hashFileBytes(encodeTextFile(normalizedReplaced, sourceEncoding)),
|
|
182
175
|
}, state);
|
|
@@ -193,6 +186,7 @@ async function call(input, state, cwd) {
|
|
|
193
186
|
ok: true,
|
|
194
187
|
content: `已编辑 ${filePath}\n` +
|
|
195
188
|
`替换次数:${replaceAll ? occurrences : 1}\n` +
|
|
189
|
+
`已回读核验提交内容(不代表代码测试通过)。\n` +
|
|
196
190
|
`行数:${linesBefore} → ${linesAfter}\n` + (patch.unifiedDiff ?? '[diff omitted: too large]'),
|
|
197
191
|
metadata: await fileActivityMetadata(filePath, 'modified', 'not_run', 'support', cwd, patch),
|
|
198
192
|
};
|
|
@@ -208,6 +202,8 @@ async function call(input, state, cwd) {
|
|
|
208
202
|
* 3. 返回重叠率最高的前 N 行作为提示
|
|
209
203
|
*/
|
|
210
204
|
function findFuzzyMatchHint(fileContent, target) {
|
|
205
|
+
if (fileContent.length > 200_000 || target.length > 2_000)
|
|
206
|
+
return '大文件不执行全文件模糊匹配;用 Grep 定位,再 Read 目标范围获取精确文本。';
|
|
211
207
|
const MIN_OVERLAP_RATIO = 0.5; // 至少 50% 字符重叠才认为"接近"
|
|
212
208
|
const MAX_HINTS = 3; // 最多提示 3 个候选
|
|
213
209
|
const targetTrimmed = target.trim();
|
package/src/tools/edit/edit.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* v0.2 增强:
|
|
9
9
|
* - 路径安全校验(null byte / UNC 路径)
|
|
10
|
-
* -
|
|
10
|
+
* - 共享有界编辑事务与提交后回读核验
|
|
11
11
|
* - old_string 找不到时提供模糊匹配提示(最常见模型错误)
|
|
12
12
|
* ============================================================
|
|
13
13
|
*/
|
|
@@ -36,9 +36,8 @@ import { assertFresh, hashFileBytes, recordRead, wasTruncated, type FileReadStat
|
|
|
36
36
|
import { fileActivityMetadata } from '../shared/fileActivity.js';
|
|
37
37
|
import { emitFileMutation } from '../shared/fileMutationBus.js';
|
|
38
38
|
import { buildPatchSummary } from '../shared/diffSummary.js';
|
|
39
|
+
import { readEditSnapshot, commitEdit } from '../shared/editTransaction.js';
|
|
39
40
|
|
|
40
|
-
/** 编辑文件大小上限:1GB。防止模型试图 Edit 超大文件撑爆内存。 */
|
|
41
|
-
const MAX_EDIT_FILE_SIZE_BYTES = 1024 * 1024 * 1024;
|
|
42
41
|
|
|
43
42
|
// ---------------- 1. Zod 输入 schema ----------------
|
|
44
43
|
const inputSchema = z.object({
|
|
@@ -65,7 +64,7 @@ const description = `Performs exact string replacements in files.
|
|
|
65
64
|
Usage:
|
|
66
65
|
- You MUST use your \`Read\` tool to read the current content of the file BEFORE calling Edit.
|
|
67
66
|
Memory is unreliable — if you think you "remember" how the code looks, you are probably wrong.
|
|
68
|
-
|
|
67
|
+
Read the relevant sections; reuse unchanged read evidence instead of repeatedly reading the whole file.
|
|
69
68
|
- To create a new file, pass an empty \`old_string\` and the desired contents as \`new_string\`. When creating a new file (path does not yet exist), you may call Edit directly — no prior Read is needed.
|
|
70
69
|
- If the file already exists and you pass an empty \`old_string\`, the edit is rejected with a clear error. To replace the entire content of an existing file, use the Write tool instead.
|
|
71
70
|
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
|
@@ -145,9 +144,11 @@ async function call(input: EditInput, state?: FileReadState, cwd?: string): Prom
|
|
|
145
144
|
}
|
|
146
145
|
|
|
147
146
|
let original: string;
|
|
147
|
+
let editSnapshot: Awaited<ReturnType<typeof readEditSnapshot>>;
|
|
148
148
|
let sourceEncoding: Awaited<ReturnType<typeof readTextFile>>['encoding'];
|
|
149
149
|
try {
|
|
150
|
-
const snapshot = await
|
|
150
|
+
const snapshot = await readEditSnapshot(filePath);
|
|
151
|
+
editSnapshot = snapshot;
|
|
151
152
|
original = snapshot.content;
|
|
152
153
|
sourceEncoding = snapshot.encoding;
|
|
153
154
|
} catch (e) {
|
|
@@ -157,15 +158,6 @@ async function call(input: EditInput, state?: FileReadState, cwd?: string): Prom
|
|
|
157
158
|
// 5d-1. 记录原文件行尾符风格(写入时保持一致)
|
|
158
159
|
const originalLineEnding = detectLineEndingsForString(original);
|
|
159
160
|
|
|
160
|
-
// 5d. 文件大小上限检查
|
|
161
|
-
const fileSize = Buffer.byteLength(original, 'utf8');
|
|
162
|
-
if (fileSize > MAX_EDIT_FILE_SIZE_BYTES) {
|
|
163
|
-
return {
|
|
164
|
-
ok: false,
|
|
165
|
-
error: `文件过大(${fileSize} 字节 > ${MAX_EDIT_FILE_SIZE_BYTES} 字节 ≈ 1GB)。Edit 不适合操作超大文件,请用 Write 工具。`,
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
|
|
169
161
|
// 唯性检查
|
|
170
162
|
if (old_string === '') {
|
|
171
163
|
return {
|
|
@@ -212,7 +204,7 @@ async function call(input: EditInput, state?: FileReadState, cwd?: string): Prom
|
|
|
212
204
|
const normalizedReplaced = applyLineEnding(replaced, originalLineEnding);
|
|
213
205
|
|
|
214
206
|
try {
|
|
215
|
-
await
|
|
207
|
+
await commitEdit(filePath, editSnapshot, normalizedReplaced);
|
|
216
208
|
recordRead(filePath, {
|
|
217
209
|
contentHash: hashFileBytes(encodeTextFile(normalizedReplaced, sourceEncoding)),
|
|
218
210
|
}, state);
|
|
@@ -230,6 +222,7 @@ async function call(input: EditInput, state?: FileReadState, cwd?: string): Prom
|
|
|
230
222
|
content:
|
|
231
223
|
`已编辑 ${filePath}\n` +
|
|
232
224
|
`替换次数:${replaceAll ? occurrences : 1}\n` +
|
|
225
|
+
`已回读核验提交内容(不代表代码测试通过)。\n` +
|
|
233
226
|
`行数:${linesBefore} → ${linesAfter}\n` + (patch.unifiedDiff ?? '[diff omitted: too large]'),
|
|
234
227
|
metadata: await fileActivityMetadata(filePath, 'modified', 'not_run', 'support', cwd, patch),
|
|
235
228
|
};
|
|
@@ -247,6 +240,8 @@ async function call(input: EditInput, state?: FileReadState, cwd?: string): Prom
|
|
|
247
240
|
* 3. 返回重叠率最高的前 N 行作为提示
|
|
248
241
|
*/
|
|
249
242
|
function findFuzzyMatchHint(fileContent: string, target: string): string | null {
|
|
243
|
+
if (fileContent.length > 200_000 || target.length > 2_000)
|
|
244
|
+
return '大文件不执行全文件模糊匹配;用 Grep 定位,再 Read 目标范围获取精确文本。';
|
|
250
245
|
const MIN_OVERLAP_RATIO = 0.5; // 至少 50% 字符重叠才认为"接近"
|
|
251
246
|
const MAX_HINTS = 3; // 最多提示 3 个候选
|
|
252
247
|
|
|
@@ -17,12 +17,13 @@ import { existsSync } from 'node:fs';
|
|
|
17
17
|
import { z } from 'zod';
|
|
18
18
|
import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
|
|
19
19
|
import { toToolParameters } from '../../utils/zodToJson.js';
|
|
20
|
-
import { resolveToolPath, detectLineEndingsForString, applyLineEnding, findActualString, preserveQuoteStyle, countOccurrences, splitReplaceAll, tryAutoSnapshot,
|
|
20
|
+
import { resolveToolPath, detectLineEndingsForString, applyLineEnding, findActualString, preserveQuoteStyle, countOccurrences, splitReplaceAll, tryAutoSnapshot, encodeTextFile, } from '../shared/fileUtils.js';
|
|
21
21
|
import { filePathField } from '../shared/schemas.js';
|
|
22
22
|
import { assertFresh, hashFileBytes, recordRead, wasTruncated } from '../shared/fileState.js';
|
|
23
23
|
import { fileActivityMetadata } from '../shared/fileActivity.js';
|
|
24
24
|
import { emitFileMutation } from '../shared/fileMutationBus.js';
|
|
25
25
|
import { buildPatchSummary } from '../shared/diffSummary.js';
|
|
26
|
+
import { readEditSnapshot, commitEdit } from '../shared/editTransaction.js';
|
|
26
27
|
// ---------------- 1. Zod 输入 schema ----------------
|
|
27
28
|
const editItemSchema = z.object({
|
|
28
29
|
old_string: z.string().min(1).describe('要替换的原文本(不允许为空 —— 创建新文件请用 Edit 工具)'),
|
|
@@ -108,9 +109,11 @@ async function call(input, state, cwd) {
|
|
|
108
109
|
}
|
|
109
110
|
// 4e. 读原文
|
|
110
111
|
let originalContent;
|
|
112
|
+
let editSnapshot;
|
|
111
113
|
let sourceEncoding;
|
|
112
114
|
try {
|
|
113
|
-
const snapshot = await
|
|
115
|
+
const snapshot = await readEditSnapshot(filePath);
|
|
116
|
+
editSnapshot = snapshot;
|
|
114
117
|
originalContent = snapshot.content;
|
|
115
118
|
sourceEncoding = snapshot.encoding;
|
|
116
119
|
}
|
|
@@ -119,6 +122,8 @@ async function call(input, state, cwd) {
|
|
|
119
122
|
}
|
|
120
123
|
// 4f. 在内存中顺序应用所有 edit(任一失败立即返回,磁盘不动)
|
|
121
124
|
let currentContent = originalContent;
|
|
125
|
+
let replacements = 0;
|
|
126
|
+
let unchanged = 0;
|
|
122
127
|
const lineEnding = detectLineEndingsForString(originalContent);
|
|
123
128
|
const truncHint = wasTruncated(filePath, state)
|
|
124
129
|
? `\n\n⚠️ 上次 Read 该文件时被截断了(只看到部分内容)。当前 old_string 可能在截断范围外。请用更小的 limit + offset 精确读取目标行号附近后再 MultiEdit。`
|
|
@@ -153,6 +158,11 @@ async function call(input, state, cwd) {
|
|
|
153
158
|
error: `edits[${i}].old_string 在当前内容中出现 ${occurrences} 次,不唯一。请扩大 old_string 包含更多上下文,或显式传 replace_all=true。${truncHint}`,
|
|
154
159
|
};
|
|
155
160
|
}
|
|
161
|
+
if (searchTarget === processedNewString) {
|
|
162
|
+
unchanged++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
replacements += replaceAll ? occurrences : 1;
|
|
156
166
|
currentContent = replaceAll
|
|
157
167
|
? splitReplaceAll(currentContent, searchTarget, processedNewString)
|
|
158
168
|
: currentContent.replace(searchTarget, () => processedNewString);
|
|
@@ -160,7 +170,7 @@ async function call(input, state, cwd) {
|
|
|
160
170
|
// 4g. 全部成功 → 保持原文件行尾符风格,写盘
|
|
161
171
|
const finalContent = applyLineEnding(currentContent, lineEnding);
|
|
162
172
|
try {
|
|
163
|
-
await
|
|
173
|
+
await commitEdit(filePath, editSnapshot, finalContent);
|
|
164
174
|
recordRead(filePath, {
|
|
165
175
|
contentHash: hashFileBytes(encodeTextFile(finalContent, sourceEncoding)),
|
|
166
176
|
}, state);
|
|
@@ -172,7 +182,7 @@ async function call(input, state, cwd) {
|
|
|
172
182
|
const patch = buildPatchSummary(originalContent, finalContent, input.file_path);
|
|
173
183
|
return {
|
|
174
184
|
ok: true,
|
|
175
|
-
content:
|
|
185
|
+
content: `已编辑 ${filePath}:${edits.length} 条操作,实际替换 ${replacements} 次,${unchanged} 条无变化。已回读核验提交内容。\n${patch.unifiedDiff ?? '[diff 未统计:文件过大;不代表无变化]'}`,
|
|
176
186
|
metadata: await fileActivityMetadata(filePath, 'modified', 'not_run', 'support', cwd, patch),
|
|
177
187
|
};
|
|
178
188
|
}
|
|
@@ -29,7 +29,6 @@ import {
|
|
|
29
29
|
countOccurrences,
|
|
30
30
|
splitReplaceAll,
|
|
31
31
|
tryAutoSnapshot,
|
|
32
|
-
atomicWriteFile,
|
|
33
32
|
encodeTextFile,
|
|
34
33
|
readTextFile,
|
|
35
34
|
} from '../shared/fileUtils.js';
|
|
@@ -38,6 +37,7 @@ import { assertFresh, hashFileBytes, recordRead, wasTruncated, type FileReadStat
|
|
|
38
37
|
import { fileActivityMetadata } from '../shared/fileActivity.js';
|
|
39
38
|
import { emitFileMutation } from '../shared/fileMutationBus.js';
|
|
40
39
|
import { buildPatchSummary } from '../shared/diffSummary.js';
|
|
40
|
+
import { readEditSnapshot, commitEdit } from '../shared/editTransaction.js';
|
|
41
41
|
|
|
42
42
|
// ---------------- 1. Zod 输入 schema ----------------
|
|
43
43
|
const editItemSchema = z.object({
|
|
@@ -134,9 +134,11 @@ async function call(input: MultiEditInput, state?: FileReadState, cwd?: string):
|
|
|
134
134
|
|
|
135
135
|
// 4e. 读原文
|
|
136
136
|
let originalContent: string;
|
|
137
|
+
let editSnapshot: Awaited<ReturnType<typeof readEditSnapshot>>;
|
|
137
138
|
let sourceEncoding: Awaited<ReturnType<typeof readTextFile>>['encoding'];
|
|
138
139
|
try {
|
|
139
|
-
const snapshot = await
|
|
140
|
+
const snapshot = await readEditSnapshot(filePath);
|
|
141
|
+
editSnapshot = snapshot;
|
|
140
142
|
originalContent = snapshot.content;
|
|
141
143
|
sourceEncoding = snapshot.encoding;
|
|
142
144
|
} catch (e) {
|
|
@@ -145,6 +147,8 @@ async function call(input: MultiEditInput, state?: FileReadState, cwd?: string):
|
|
|
145
147
|
|
|
146
148
|
// 4f. 在内存中顺序应用所有 edit(任一失败立即返回,磁盘不动)
|
|
147
149
|
let currentContent = originalContent;
|
|
150
|
+
let replacements = 0;
|
|
151
|
+
let unchanged = 0;
|
|
148
152
|
const lineEnding = detectLineEndingsForString(originalContent);
|
|
149
153
|
const truncHint = wasTruncated(filePath, state)
|
|
150
154
|
? `\n\n⚠️ 上次 Read 该文件时被截断了(只看到部分内容)。当前 old_string 可能在截断范围外。请用更小的 limit + offset 精确读取目标行号附近后再 MultiEdit。`
|
|
@@ -182,6 +186,8 @@ async function call(input: MultiEditInput, state?: FileReadState, cwd?: string):
|
|
|
182
186
|
};
|
|
183
187
|
}
|
|
184
188
|
|
|
189
|
+
if (searchTarget === processedNewString) { unchanged++; continue; }
|
|
190
|
+
replacements += replaceAll ? occurrences : 1;
|
|
185
191
|
currentContent = replaceAll
|
|
186
192
|
? splitReplaceAll(currentContent, searchTarget, processedNewString)
|
|
187
193
|
: currentContent.replace(searchTarget, () => processedNewString);
|
|
@@ -191,7 +197,7 @@ async function call(input: MultiEditInput, state?: FileReadState, cwd?: string):
|
|
|
191
197
|
const finalContent = applyLineEnding(currentContent, lineEnding);
|
|
192
198
|
|
|
193
199
|
try {
|
|
194
|
-
await
|
|
200
|
+
await commitEdit(filePath, editSnapshot, finalContent);
|
|
195
201
|
recordRead(filePath, {
|
|
196
202
|
contentHash: hashFileBytes(encodeTextFile(finalContent, sourceEncoding)),
|
|
197
203
|
}, state);
|
|
@@ -203,7 +209,7 @@ async function call(input: MultiEditInput, state?: FileReadState, cwd?: string):
|
|
|
203
209
|
const patch = buildPatchSummary(originalContent, finalContent, input.file_path);
|
|
204
210
|
return {
|
|
205
211
|
ok: true,
|
|
206
|
-
content:
|
|
212
|
+
content: `已编辑 ${filePath}:${edits.length} 条操作,实际替换 ${replacements} 次,${unchanged} 条无变化。已回读核验提交内容。\n${patch.unifiedDiff ?? '[diff 未统计:文件过大;不代表无变化]'}`,
|
|
207
213
|
metadata: await fileActivityMetadata(filePath, 'modified', 'not_run', 'support', cwd, patch),
|
|
208
214
|
};
|
|
209
215
|
}
|
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 {
|
|
@@ -16,6 +16,7 @@ export function recordRead(absPath, opts, state = defaultState) {
|
|
|
16
16
|
&& st.size === prev.size;
|
|
17
17
|
const sameRegionUnchanged = sameSnapshot
|
|
18
18
|
&& prev.offset === opts?.offset
|
|
19
|
+
&& prev.column === opts?.column
|
|
19
20
|
&& prev.limit === opts?.limit;
|
|
20
21
|
const reads = sameRegionUnchanged ? (prev.reads ?? 1) + 1 : 1;
|
|
21
22
|
// A bounded Read may be repeated in several adjacent calls. Keep the
|
|
@@ -64,6 +65,7 @@ export function recordRead(absPath, opts, state = defaultState) {
|
|
|
64
65
|
truncated: opts?.truncated ?? false,
|
|
65
66
|
partial,
|
|
66
67
|
offset: opts?.offset,
|
|
68
|
+
column: opts?.column,
|
|
67
69
|
limit: opts?.limit,
|
|
68
70
|
reads,
|
|
69
71
|
...(opts?.contentHash ? { contentHash: opts.contentHash } : {}),
|
|
@@ -7,6 +7,7 @@ export interface FileStateEntry {
|
|
|
7
7
|
truncated: boolean;
|
|
8
8
|
partial: boolean;
|
|
9
9
|
offset?: number;
|
|
10
|
+
column?: number;
|
|
10
11
|
limit?: number;
|
|
11
12
|
reads?: number;
|
|
12
13
|
/** SHA-256 is recorded only for a complete read. */
|
|
@@ -42,6 +43,7 @@ export function recordRead(
|
|
|
42
43
|
truncated?: boolean;
|
|
43
44
|
partial?: boolean;
|
|
44
45
|
offset?: number;
|
|
46
|
+
column?: number;
|
|
45
47
|
limit?: number;
|
|
46
48
|
contentHash?: string;
|
|
47
49
|
range?: FileReadRange;
|
|
@@ -57,6 +59,7 @@ export function recordRead(
|
|
|
57
59
|
&& st.size === prev.size;
|
|
58
60
|
const sameRegionUnchanged = sameSnapshot
|
|
59
61
|
&& prev.offset === opts?.offset
|
|
62
|
+
&& prev.column === opts?.column
|
|
60
63
|
&& prev.limit === opts?.limit;
|
|
61
64
|
const reads = sameRegionUnchanged ? (prev.reads ?? 1) + 1 : 1;
|
|
62
65
|
|
|
@@ -107,6 +110,7 @@ export function recordRead(
|
|
|
107
110
|
truncated: opts?.truncated ?? false,
|
|
108
111
|
partial,
|
|
109
112
|
offset: opts?.offset,
|
|
113
|
+
column: opts?.column,
|
|
110
114
|
limit: opts?.limit,
|
|
111
115
|
reads,
|
|
112
116
|
...(opts?.contentHash ? { contentHash: opts.contentHash } : {}),
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Conservative chars/token reservation works for mixed code and CJK text.
|
|
2
|
+
* A response's parallel Read calls share this closure, not separate quotas.
|
|
3
|
+
*/
|
|
4
|
+
export function createReadBudget(remainingTokens) {
|
|
5
|
+
let available = Math.max(0, Math.floor(remainingTokens * 0.5));
|
|
6
|
+
const perRead = Math.min(1_000_000, Math.max(1000, Math.floor(available / 2)));
|
|
7
|
+
return (requested = Math.min(240_000, perRead)) => {
|
|
8
|
+
const granted = Math.min(Math.max(0, requested), perRead, available);
|
|
9
|
+
available -= granted;
|
|
10
|
+
return granted;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Conservative chars/token reservation works for mixed code and CJK text.
|
|
2
|
+
* A response's parallel Read calls share this closure, not separate quotas.
|
|
3
|
+
*/
|
|
4
|
+
export function createReadBudget(remainingTokens: number) {
|
|
5
|
+
let available = Math.max(0, Math.floor(remainingTokens * 0.5));
|
|
6
|
+
const perRead = Math.min(1_000_000, Math.max(1000, Math.floor(available / 2)));
|
|
7
|
+
return (requested = Math.min(240_000, perRead)) => {
|
|
8
|
+
const granted = Math.min(Math.max(0, requested), perRead, available);
|
|
9
|
+
available -= granted;
|
|
10
|
+
return granted;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -88,7 +88,7 @@ export const BUILTIN_TOOL_PROMPT_GUIDES = {
|
|
|
88
88
|
// 测试就是从这次真实回归里补的,锁住"压缩后仍要包含这些内容"而不是简单地
|
|
89
89
|
// 锁住某个具体的压缩产物。
|
|
90
90
|
export const CONTRACT_DESCRIPTION_OVERRIDES = {
|
|
91
|
-
Read: '读取文本、图片、Notebook 或 PDF
|
|
91
|
+
Read: '读取文本、图片、Notebook 或 PDF。offset 从 1 开始;大范围阅读可提高 limit/max_chars,实际预算由 Runtime 分配。按回执 offset/column 续读,不能跳过截断行。PDF 用 pages,一次最多 20 页。',
|
|
92
92
|
Edit: '精确替换已有文本。先 Read 最新内容,old_string 必须唯一;多处相同替换传 replace_all=true。new_string 按字面写入。',
|
|
93
93
|
Write: '创建文件或整体覆盖;新文件可直接写入。覆盖已有文件前须完整 Read(一次全量或分段覆盖全部范围)且内容未变化;仅有局部读取时使用 Edit/ApplyPatch。',
|
|
94
94
|
ApplyPatch: '提交 Codex 格式补丁,必须用 *** Begin Patch / *** End Patch 包络,不接受裸 git diff。支持 Add/Update/Delete File、Move to;路径相对工作区。先校验,写入失败尝试回滚,不保证跨文件断电原子性。',
|
|
@@ -112,7 +112,7 @@ export const CONTRACT_DESCRIPTION_OVERRIDES = {
|
|
|
112
112
|
Advisor: '向无工具权限的顾问模型请求一次结构化第二意见。默认 reviewMode=brief 只给重点短评;完整代码审查用 full,并提供范围、基线和证据。full 不扩大上下文或输出预算,材料不足时必须说明未完成。',
|
|
113
113
|
// 保留决策必填条件,与 schema 的 superRefine 一致;不能只概括成“记录决定”。
|
|
114
114
|
AdvisorDecision: '记录主 Agent 对顾问第二意见的 accept、partial 或 reject 判断;是否采纳由主 Agent 决定,登记不是执行许可。partial 同时填 acceptedPoints/rejectedPoints;reject 填 rejectedPoints,列出被拒绝点及依据;evidenceRefs 只引用真实 tool_call_id,没有引用则记录待验证。登记失败不阻止任务继续。',
|
|
115
|
-
MultiEdit: '同文件有序批量精确替换,先 Read
|
|
115
|
+
MultiEdit: '同文件有序批量精确替换,先 Read 相关范围;全部匹配成功才写盘。old_string 非空且唯一,或显式 replace_all。后续匹配前序生成文本会被拒绝,请合并 edits。操作条数不等于替换次数,diff 预览可能不完整。',
|
|
116
116
|
LSP: '查询定义、引用、类型、符号和诊断。line/character 为 1-based UTF-16;尚未收到诊断不等于没有错误,编辑前读取源码。',
|
|
117
117
|
WebFetch: '读取已知 HTTP(S) URL 的静态文本,HTML 转 Markdown;不执行 JavaScript、不继承登录态。PDF/图片用 Download 后 Read;动态页面用 WebBrowser。',
|
|
118
118
|
WebSearch: 'Tavily 搜索返回标题、URL、片段及可选综合答案,不是完整原文或浏览器。重要结论读取来源正文;按子问题查询,重试改变关键词或来源。recency 控制时效,历史范围用 start_date/end_date,none 关闭自动时效;advanced 更慢,news 偏新闻。需配置 Tavily。',
|
|
@@ -105,7 +105,7 @@ export const BUILTIN_TOOL_PROMPT_GUIDES: Readonly<Record<string, ToolPromptGuide
|
|
|
105
105
|
// 测试就是从这次真实回归里补的,锁住"压缩后仍要包含这些内容"而不是简单地
|
|
106
106
|
// 锁住某个具体的压缩产物。
|
|
107
107
|
export const CONTRACT_DESCRIPTION_OVERRIDES: Readonly<Record<string, string | (() => string)>> = {
|
|
108
|
-
Read: '读取文本、图片、Notebook 或 PDF
|
|
108
|
+
Read: '读取文本、图片、Notebook 或 PDF。offset 从 1 开始;大范围阅读可提高 limit/max_chars,实际预算由 Runtime 分配。按回执 offset/column 续读,不能跳过截断行。PDF 用 pages,一次最多 20 页。',
|
|
109
109
|
Edit: '精确替换已有文本。先 Read 最新内容,old_string 必须唯一;多处相同替换传 replace_all=true。new_string 按字面写入。',
|
|
110
110
|
Write: '创建文件或整体覆盖;新文件可直接写入。覆盖已有文件前须完整 Read(一次全量或分段覆盖全部范围)且内容未变化;仅有局部读取时使用 Edit/ApplyPatch。',
|
|
111
111
|
ApplyPatch: '提交 Codex 格式补丁,必须用 *** Begin Patch / *** End Patch 包络,不接受裸 git diff。支持 Add/Update/Delete File、Move to;路径相对工作区。先校验,写入失败尝试回滚,不保证跨文件断电原子性。',
|
|
@@ -130,7 +130,7 @@ export const CONTRACT_DESCRIPTION_OVERRIDES: Readonly<Record<string, string | ((
|
|
|
130
130
|
Advisor: '向无工具权限的顾问模型请求一次结构化第二意见。默认 reviewMode=brief 只给重点短评;完整代码审查用 full,并提供范围、基线和证据。full 不扩大上下文或输出预算,材料不足时必须说明未完成。',
|
|
131
131
|
// 保留决策必填条件,与 schema 的 superRefine 一致;不能只概括成“记录决定”。
|
|
132
132
|
AdvisorDecision: '记录主 Agent 对顾问第二意见的 accept、partial 或 reject 判断;是否采纳由主 Agent 决定,登记不是执行许可。partial 同时填 acceptedPoints/rejectedPoints;reject 填 rejectedPoints,列出被拒绝点及依据;evidenceRefs 只引用真实 tool_call_id,没有引用则记录待验证。登记失败不阻止任务继续。',
|
|
133
|
-
MultiEdit: '同文件有序批量精确替换,先 Read
|
|
133
|
+
MultiEdit: '同文件有序批量精确替换,先 Read 相关范围;全部匹配成功才写盘。old_string 非空且唯一,或显式 replace_all。后续匹配前序生成文本会被拒绝,请合并 edits。操作条数不等于替换次数,diff 预览可能不完整。',
|
|
134
134
|
LSP: '查询定义、引用、类型、符号和诊断。line/character 为 1-based UTF-16;尚未收到诊断不等于没有错误,编辑前读取源码。',
|
|
135
135
|
WebFetch: '读取已知 HTTP(S) URL 的静态文本,HTML 转 Markdown;不执行 JavaScript、不继承登录态。PDF/图片用 Download 后 Read;动态页面用 WebBrowser。',
|
|
136
136
|
WebSearch: 'Tavily 搜索返回标题、URL、片段及可选综合答案,不是完整原文或浏览器。重要结论读取来源正文;按子问题查询,重试改变关键词或来源。recency 控制时效,历史范围用 start_date/end_date,none 关闭自动时效;advanced 更慢,news 偏新闻。需配置 Tavily。',
|
package/src/tools/types.js
CHANGED
|
@@ -11,11 +11,7 @@ export const DEFAULT_MAX_RESULT_SIZE_CHARS = 30_000;
|
|
|
11
11
|
/** Read 工具默认行数上限(与 kakadeai 主仓库一致) */
|
|
12
12
|
export const MAX_LINES_TO_READ = 2000;
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* - system prompt + 工具定义 ≈ 30KB
|
|
17
|
-
* - 对话历史 ≈ 100KB
|
|
18
|
-
* - 单次读取安全上限 ≈ 256KB(~64K tokens)
|
|
19
|
-
* 超过此限制请用 offset/limit 分段读。
|
|
14
|
+
* Text input IO ceiling, independent of the model's returned-content budget.
|
|
15
|
+
* Read windows are bounded separately and can reserve runtime context capacity.
|
|
20
16
|
*/
|
|
21
17
|
export const MAX_FILE_SIZE_BYTES = 1024 * 1024 * 1024; // 1 GiB; returned content remains independently bounded
|
package/src/tools/types.ts
CHANGED
|
@@ -14,11 +14,7 @@ export const DEFAULT_MAX_RESULT_SIZE_CHARS = 30_000;
|
|
|
14
14
|
export const MAX_LINES_TO_READ = 2000;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* - system prompt + 工具定义 ≈ 30KB
|
|
20
|
-
* - 对话历史 ≈ 100KB
|
|
21
|
-
* - 单次读取安全上限 ≈ 256KB(~64K tokens)
|
|
22
|
-
* 超过此限制请用 offset/limit 分段读。
|
|
17
|
+
* Text input IO ceiling, independent of the model's returned-content budget.
|
|
18
|
+
* Read windows are bounded separately and can reserve runtime context capacity.
|
|
23
19
|
*/
|
|
24
20
|
export const MAX_FILE_SIZE_BYTES = 1024 * 1024 * 1024; // 1 GiB; returned content remains independently bounded
|
package/src/types.ts
CHANGED
|
@@ -291,8 +291,8 @@ export interface FilePatchSummary {
|
|
|
291
291
|
unifiedDiff?: string;
|
|
292
292
|
beforeHash?: string;
|
|
293
293
|
afterHash: string;
|
|
294
|
-
additions
|
|
295
|
-
deletions
|
|
294
|
+
additions?: number;
|
|
295
|
+
deletions?: number;
|
|
296
296
|
truncated: boolean;
|
|
297
297
|
omittedReason?: 'too_large';
|
|
298
298
|
}
|
|
@@ -570,6 +570,8 @@ export type ToolResult = { problem?: ToolProblemOutcome; observation?: ReadObser
|
|
|
570
570
|
* 避免按工具名写特判分支。
|
|
571
571
|
*/
|
|
572
572
|
export interface ToolCallContext {
|
|
573
|
+
/** Shared per-model-response read allowance; reservations happen before IO. */
|
|
574
|
+
reserveReadChars?: (requested?: number) => number;
|
|
573
575
|
/** Host presentation only; cannot grant tools to a delegated agent. */
|
|
574
576
|
output?: import('./runtime/outputContract.js').OutputContract;
|
|
575
577
|
/** Live effective catalog, including per-turn restrictions. */
|