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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimal-agent",
3
- "version": "0.18.9",
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} (+${patch.additions} -${patch.deletions})`);
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} (+${patch.additions} -${patch.deletions})`,
406
+ `${file.activity}: ${file.label} (${patch.additions === undefined ? 'diff 未统计' : `+${patch.additions} -${patch.deletions}`})`,
407
407
  );
408
408
  }
409
409
  return {
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * v0.2 增强:
9
9
  * - 路径安全校验(null byte / UNC 路径)
10
- * - 文件大小上限(1GB)
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, readTextFile, } from '../shared/fileUtils.js';
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
- /** 编辑文件大小上限:1GB。防止模型试图 Edit 超大文件撑爆内存。 */
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
- Re-read the relevant sections (function, module, or area you plan to change) every time.
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 readTextFile(filePath);
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 atomicWriteFile(filePath, normalizedReplaced, sourceEncoding);
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();
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * v0.2 增强:
9
9
  * - 路径安全校验(null byte / UNC 路径)
10
- * - 文件大小上限(1GB)
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
- Re-read the relevant sections (function, module, or area you plan to change) every time.
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 readTextFile(filePath);
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 atomicWriteFile(filePath, normalizedReplaced, sourceEncoding);
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, atomicWriteFile, encodeTextFile, readTextFile, } from '../shared/fileUtils.js';
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 readTextFile(filePath);
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 atomicWriteFile(filePath, finalContent, sourceEncoding);
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: `已对 ${filePath} 应用 ${edits.length} 处修改\n${patch.unifiedDiff ?? '[diff omitted: too large]'}`,
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 readTextFile(filePath);
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 atomicWriteFile(filePath, finalContent, sourceEncoding);
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: `已对 ${filePath} 应用 ${edits.length} 处修改\n${patch.unifiedDiff ?? '[diff omitted: too large]'}`,
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
  }
@@ -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 = 0;
136
+ let line = 1;
138
137
  let hasMore = false;
139
- for await (const value of rl) {
140
- line += 1;
141
- if (line >= offset && lines.length < limit)
142
- lines.push(`${line}\t${value}`);
143
- else if (line >= offset + limit) {
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
- rl.close();
146
- stream.destroy();
147
- break;
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
- if (info.size <= SMALL_TEXT_BYTES) {
355
- const snapshot = await readTextFile(filePath);
356
- completeBytes = snapshot.bytes;
357
- const all = snapshot.content.length === 0 ? [] : snapshot.content.split('\n');
358
- const start = offset - 1;
359
- const selected = all.slice(start, start + limit);
360
- content = selected
361
- .map((line, index) => `${start + index + 1}\t${line}`)
362
- .join('\n');
363
- totalLines = all.length;
364
- selectedCount = selected.length;
365
- hasMore = start + selected.length < all.length;
366
- totalUnits = totalLines;
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 > DEFAULT_MAX_RESULT_SIZE_CHARS) {
381
- content = `${content.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS)}\n\n⚠️ 重要:Read 输出已截断;截断范围外仍有内容。请用较小 limit 重新读取本区间,避免跳过截断行;精确修改用 Edit/ApplyPatch,Write 覆盖要求完整读取(可分段覆盖全部范围)。`;
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 + limit})`;
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: DEFAULT_MAX_RESULT_SIZE_CHARS,
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
  };
@@ -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 = 0;
194
+ let line = 1;
193
195
  let hasMore = false;
194
- for await (const value of rl) {
195
- line += 1;
196
- if (line >= offset && lines.length < limit) lines.push(`${line}\t${value}`);
197
- else if (line >= offset + limit) {
198
- hasMore = true;
199
- rl.close();
200
- stream.destroy();
201
- break;
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
- if (info.size <= SMALL_TEXT_BYTES) {
458
- const snapshot = await readTextFile(filePath);
459
- completeBytes = snapshot.bytes;
460
- const all =
461
- snapshot.content.length === 0 ? [] : snapshot.content.split('\n');
462
- const start = offset - 1;
463
- const selected = all.slice(start, start + limit);
464
- content = selected
465
- .map((line, index) => `${start + index + 1}\t${line}`)
466
- .join('\n');
467
- totalLines = all.length;
468
- selectedCount = selected.length;
469
- hasMore = start + selected.length < all.length;
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 > DEFAULT_MAX_RESULT_SIZE_CHARS) {
483
- content = `${content.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS)}\n\n⚠️ 重要:Read 输出已截断;截断范围外仍有内容。请用较小 limit 重新读取本区间,避免跳过截断行;精确修改用 Edit/ApplyPatch,Write 覆盖要求完整读取(可分段覆盖全部范围)。`;
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 + limit})`;
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: DEFAULT_MAX_RESULT_SIZE_CHARS,
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 { readFile, realpath } from 'node:fs/promises';
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
- sha256 = createHash('sha256').update(await readFile(candidate)).digest('hex');
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 { readFile, realpath } from 'node:fs/promises';
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 { sha256 = createHash('sha256').update(await readFile(candidate)).digest('hex'); }
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。文本 offset 从 1 开始;截断后缩小 limit 重读区间,不能跳过未显示内容。PDF 用 pages 指定范围,一次最多 20 页。',
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;全部匹配成功才写盘。old_string 非空且唯一,或显式 replace_all。后续匹配前序生成文本会被拒绝,请合并 edits。',
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。文本 offset 从 1 开始;截断后缩小 limit 重读区间,不能跳过未显示内容。PDF 用 pages 指定范围,一次最多 20 页。',
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;全部匹配成功才写盘。old_string 非空且唯一,或显式 replace_all。后续匹配前序生成文本会被拒绝,请合并 edits。',
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。',
@@ -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
- * Read 工具默认字节上限(防止超大文件灌进上下文)。
15
- * 基于 200K 上下文窗口设计:
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
@@ -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
- * Read 工具默认字节上限(防止超大文件灌进上下文)。
18
- * 基于 200K 上下文窗口设计:
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: number;
295
- deletions: number;
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. */