minimal-agent 0.5.4 → 0.5.6

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/src/loop.js CHANGED
@@ -15,18 +15,23 @@
15
15
  * a. 自动压缩检查(防止 context 撑爆)
16
16
  * b. 调 LLM,把流式响应组装成完整的 assistant message
17
17
  * c. 如果 assistant 不调工具 → 结束,return
18
- * d. 否则按顺序执行每个工具调用,把 tool 消息追加到历史
18
+ * d. 否则并行执行所有工具调用(OpenAI 协议要求 tool 消息按 tool_calls 顺序回填)
19
19
  * e. 进入下一轮(让模型看到 tool_result 后继续推理)
20
20
  *
21
21
  * 失控保护:maxTurns(默认 50)。
22
22
  * 中断支持:AbortSignal 透传到 chat() 和 tool.call()。
23
+ *
24
+ * "工作过程显示"事件:
25
+ * - stage_change:thinking / streaming / tool_executing / compacting
26
+ * - reasoning:思维链流式增量(仅字符串型 field)
23
27
  * ============================================================
24
28
  */
29
+ import crypto from 'node:crypto';
25
30
  import { autoCompactIfNeeded } from './context/compact.js';
26
31
  import { microCompact, incrementTurn, expireOldEntries } from './context/microCompactLite.js';
27
32
  import { isPromptTooLongError, reactiveCompactIfApplicable, } from './context/reactiveCompact.js';
28
- import { chat } from './llm/client.js';
29
- import { ALL_TOOLS, executeTool } from './tools/index.js';
33
+ import { chat as defaultChat } from './llm/client.js';
34
+ import { ALL_TOOLS, executeTool as defaultExecuteTool } from './tools/index.js';
30
35
  /**
31
36
  * 执行一次"用户输入 → 模型回答完成"的完整流程。
32
37
  *
@@ -35,8 +40,20 @@ import { ALL_TOOLS, executeTool } from './tools/index.js';
35
40
  export async function* runQuery(userInput, options) {
36
41
  const { provider, history, signal, sessionState } = options;
37
42
  const maxTurns = options.maxTurns ?? 50;
38
- // 1. 用户消息入栈
39
- history.push({ role: 'user', content: userInput });
43
+ // DI fallback:测试可传 stub,生产走真实模块单例
44
+ const chatFn = options.chat ?? defaultChat;
45
+ const executeToolFn = options.executeTool ?? defaultExecuteTool;
46
+ // 1. 用户消息入栈(v2:填 timestamp,让 MessageList 的 turn 边界横线
47
+ // 能拿到首条 user 的真实时间,否则 fallback Date.now() 会丢失"提交时刻"语义)
48
+ history.push({
49
+ role: 'user',
50
+ content: userInput,
51
+ timestamp: Date.now(),
52
+ id: crypto.randomUUID(),
53
+ });
54
+ // 立即通知 UI:用户消息已入栈,触发 bump 让 <Static> 马上把它 commit 进 scrollback,
55
+ // 不必等整轮 assistant 落定(保证用户输入第一时间置顶,符合 T-A-O-R 展示顺序)。
56
+ yield { type: 'user_message_committed' };
40
57
  // 反应式压缩自救:本 runQuery 只允许触发一次,避免压缩失败导致死循环。
41
58
  // 配合 reactiveCompact.ts 的 attempted(session 级)双层防爆。
42
59
  let reactiveAttempted = false;
@@ -50,6 +67,8 @@ export async function* runQuery(userInput, options) {
50
67
  history.push({
51
68
  role: 'user',
52
69
  content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
70
+ timestamp: Date.now(),
71
+ id: crypto.randomUUID(),
53
72
  });
54
73
  yield { type: 'error', error: '已被用户中断' };
55
74
  return;
@@ -59,10 +78,13 @@ export async function* runQuery(userInput, options) {
59
78
  const compact = await autoCompactIfNeeded(history, provider);
60
79
  if (compact.compacted) {
61
80
  yield { type: 'compact_start' };
81
+ yield { type: 'stage_change', stage: 'compacting' };
62
82
  // in-place 替换 history(保持调用方持有的引用有效)
63
83
  history.length = 0;
64
84
  history.push(...compact.messages);
65
85
  yield { type: 'compact_done', before: compact.before, after: compact.after };
86
+ // 压缩完会回到 LLM 调用,stage 退回 thinking
87
+ yield { type: 'stage_change', stage: 'thinking' };
66
88
  }
67
89
  }
68
90
  catch (e) {
@@ -83,14 +105,22 @@ export async function* runQuery(userInput, options) {
83
105
  let reasoningContent = '';
84
106
  let reasoningString = '';
85
107
  const reasoningDetails = [];
108
+ /** 首个 text_delta 时切到 streaming,再来不重复 yield */
109
+ let stageStreamingYielded = false;
110
+ // 进入 LLM 流前:等首 token / reasoning,本质是 thinking
111
+ yield { type: 'stage_change', stage: 'thinking' };
86
112
  try {
87
- for await (const ev of chat({
113
+ for await (const ev of chatFn({
88
114
  provider,
89
115
  messages: history,
90
116
  tools: ALL_TOOLS,
91
117
  signal,
92
118
  })) {
93
119
  if (ev.type === 'text_delta') {
120
+ if (!stageStreamingYielded) {
121
+ stageStreamingYielded = true;
122
+ yield { type: 'stage_change', stage: 'streaming' };
123
+ }
94
124
  assistantText += ev.delta;
95
125
  yield { type: 'text', delta: ev.delta };
96
126
  }
@@ -100,16 +130,16 @@ export async function* runQuery(userInput, options) {
100
130
  else if (ev.type === 'reasoning_delta') {
101
131
  if (ev.field === 'reasoning_content' && ev.delta) {
102
132
  reasoningContent += ev.delta;
133
+ yield { type: 'reasoning', delta: ev.delta };
103
134
  }
104
135
  else if (ev.field === 'reasoning' && ev.delta) {
105
136
  reasoningString += ev.delta;
137
+ yield { type: 'reasoning', delta: ev.delta };
106
138
  }
107
139
  else if (ev.field === 'reasoning_details' && ev.items) {
140
+ // reasoning_details 是结构化数组,没有 delta 字符串,跳过 yield
108
141
  reasoningDetails.push(...ev.items);
109
142
  }
110
- if (ev.delta) {
111
- yield { type: 'reasoning_delta', field: ev.field, delta: ev.delta };
112
- }
113
143
  }
114
144
  // ev.type === 'done' 时无需处理:循环自然结束
115
145
  }
@@ -120,6 +150,8 @@ export async function* runQuery(userInput, options) {
120
150
  history.push({
121
151
  role: 'user',
122
152
  content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
153
+ timestamp: Date.now(),
154
+ id: crypto.randomUUID(),
123
155
  });
124
156
  yield { type: 'interrupted' };
125
157
  return;
@@ -130,6 +162,7 @@ export async function* runQuery(userInput, options) {
130
162
  if (isPromptTooLongError(e) && !reactiveAttempted) {
131
163
  reactiveAttempted = true;
132
164
  yield { type: 'compact_start' };
165
+ yield { type: 'stage_change', stage: 'compacting' };
133
166
  const result = await reactiveCompactIfApplicable(history, provider, e, sessionState?.reactive);
134
167
  if (result.recovered) {
135
168
  history.length = 0;
@@ -139,6 +172,8 @@ export async function* runQuery(userInput, options) {
139
172
  before: result.before ?? 0,
140
173
  after: result.after ?? 0,
141
174
  };
175
+ // 压缩完回到 LLM 调用
176
+ yield { type: 'stage_change', stage: 'thinking' };
142
177
  turn--; // 不消耗 turn 配额,本轮重新走
143
178
  continue;
144
179
  }
@@ -154,6 +189,8 @@ export async function* runQuery(userInput, options) {
154
189
  ...(reasoningContent ? { reasoning_content: reasoningContent } : {}),
155
190
  ...(reasoningString ? { reasoning: reasoningString } : {}),
156
191
  ...(reasoningDetails.length > 0 ? { reasoning_details: reasoningDetails } : {}),
192
+ timestamp: Date.now(),
193
+ id: crypto.randomUUID(),
157
194
  };
158
195
  history.push(assistantMsg);
159
196
  yield { type: 'assistant_message', message: assistantMsg };
@@ -162,41 +199,116 @@ export async function* runQuery(userInput, options) {
162
199
  yield { type: 'turn_done' };
163
200
  return;
164
201
  }
165
- // 5. 顺序执行每个工具
166
- for (const tc of toolCallsByIndex) {
167
- if (signal?.aborted) {
168
- // 中断标记帮助模型理解上下文:为什么输出被截断,用户可以重新输入
202
+ // 5. 并行执行所有工具
203
+ //
204
+ // 设计:用 Promise.allSettled 启动 N 个 worker,配合一个简易事件队列
205
+ // (queue + signalNew)把多个 worker 的 tool_start / tool_end 事件按
206
+ // 真实完成顺序交错 yield 给 UI;但 history 里的 tool 消息严格按
207
+ // toolCallsByIndex 索引顺序 push(OpenAI 协议要求 tool 消息和上一条
208
+ // assistant 的 tool_calls 一一对应)。
209
+ yield { type: 'stage_change', stage: 'tool_executing' };
210
+ const queue = [];
211
+ let signalNew = null;
212
+ const enqueue = (ev) => {
213
+ queue.push(ev);
214
+ signalNew?.();
215
+ signalNew = null;
216
+ };
217
+ const workers = toolCallsByIndex.map(async (tc) => {
218
+ enqueue({
219
+ type: 'tool_start',
220
+ toolCallId: tc.id,
221
+ toolName: tc.function.name,
222
+ argsPreview: previewArgs(tc.function.arguments),
223
+ argsFriendly: friendlyToolDescription(tc.function.name, tc.function.arguments),
224
+ });
225
+ try {
226
+ const result = await executeToolFn(tc.function.name, tc.function.arguments, signal);
227
+ enqueue({
228
+ type: 'tool_end',
229
+ toolCallId: tc.id,
230
+ toolName: tc.function.name,
231
+ ok: result.ok,
232
+ content: result.ok ? result.content : `Error: ${result.error}`,
233
+ });
234
+ return { tc, result };
235
+ }
236
+ catch (e) {
237
+ if (e.name === 'AbortError') {
238
+ enqueue({
239
+ type: 'tool_end',
240
+ toolCallId: tc.id,
241
+ toolName: tc.function.name,
242
+ ok: false,
243
+ content: '(已中断)',
244
+ });
245
+ return { tc, result: { ok: false, error: 'aborted' } };
246
+ }
247
+ const msg = e.message;
248
+ enqueue({
249
+ type: 'tool_end',
250
+ toolCallId: tc.id,
251
+ toolName: tc.function.name,
252
+ ok: false,
253
+ content: msg,
254
+ });
255
+ return { tc, result: { ok: false, error: msg } };
256
+ }
257
+ });
258
+ const allDone = Promise.allSettled(workers);
259
+ let finished = false;
260
+ allDone.then(() => {
261
+ finished = true;
262
+ signalNew?.();
263
+ signalNew = null;
264
+ });
265
+ while (!finished || queue.length > 0) {
266
+ while (queue.length > 0)
267
+ yield queue.shift();
268
+ if (finished)
269
+ break;
270
+ await new Promise((r) => {
271
+ signalNew = r;
272
+ });
273
+ }
274
+ // 严格按 toolCallsByIndex 顺序 push tool 消息进 history(OpenAI 协议要求)
275
+ const settled = await allDone;
276
+ let anyAborted = false;
277
+ for (let i = 0; i < toolCallsByIndex.length; i++) {
278
+ const tc = toolCallsByIndex[i];
279
+ const r = settled[i];
280
+ if (r.status === 'rejected') {
281
+ // 理论上 worker 已经 catch 了所有异常,这里只是防御
282
+ const errMsg = r.reason?.message ?? 'unknown';
169
283
  history.push({
170
- role: 'user',
171
- content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
284
+ role: 'tool',
285
+ tool_call_id: tc.id,
286
+ content: `Error: ${errMsg}`,
287
+ timestamp: Date.now(),
288
+ id: crypto.randomUUID(),
172
289
  });
173
- yield { type: 'error', error: '已被用户中断' };
174
- return;
290
+ continue;
175
291
  }
176
- const argsPreview = previewArgs(tc.function.arguments);
177
- yield {
178
- type: 'tool_start',
179
- toolName: tc.function.name,
180
- toolCallId: tc.id,
181
- argsPreview,
182
- };
183
- const result = await executeTool(tc.function.name, tc.function.arguments, signal);
184
- // 工具结果:失败保留错误信息,成功经微压缩后保留
292
+ const { result } = r.value;
293
+ if (!result.ok && result.error === 'aborted')
294
+ anyAborted = true;
185
295
  const rawContent = result.ok ? result.content : `Error: ${result.error}`;
186
- const content = microCompact(tc.function.name, rawContent, sessionState?.microCompact);
187
- // 工具结果作为 tool 消息回填
296
+ // 微压缩仅对成功结果走(失败结果通常很短,不需要压缩)
297
+ const content = result.ok
298
+ ? microCompact(tc.function.name, rawContent, sessionState?.microCompact)
299
+ : rawContent;
188
300
  history.push({
189
301
  role: 'tool',
190
302
  content,
191
303
  tool_call_id: tc.id,
304
+ timestamp: Date.now(),
305
+ id: crypto.randomUUID(),
192
306
  });
193
- yield {
194
- type: 'tool_end',
195
- toolName: tc.function.name,
196
- toolCallId: tc.id,
197
- ok: result.ok,
198
- content,
199
- };
307
+ }
308
+ if (anyAborted || signal?.aborted) {
309
+ // 任一工具抛 AbortError 时,外层 yield interrupted 并 return
310
+ yield { type: 'interrupted' };
311
+ return;
200
312
  }
201
313
  // 6. 继续 while 让模型看到 tool_result 后继续推理
202
314
  }
@@ -231,3 +343,47 @@ function previewArgs(rawJson) {
231
343
  return oneLine;
232
344
  return oneLine.slice(0, 60) + '...';
233
345
  }
346
+ /**
347
+ * 把工具名 + raw arguments JSON 转换成人话描述。
348
+ * UI 优先用这个显示在 spinner 行(如 "Reading src/foo.ts")。
349
+ * 已知工具走 switch;未知工具或 JSON parse 失败 fallback 到 `${toolName}(...)`。
350
+ */
351
+ function friendlyToolDescription(toolName, rawArgsJson) {
352
+ let args = {};
353
+ try {
354
+ args = JSON.parse(rawArgsJson) ?? {};
355
+ }
356
+ catch {
357
+ return `${toolName}(...)`;
358
+ }
359
+ const truncate = (s, n) => {
360
+ if (typeof s !== 'string')
361
+ return '';
362
+ return s.length > n ? s.slice(0, n) + '…' : s;
363
+ };
364
+ switch (toolName) {
365
+ case 'Read': return `Reading ${truncate(args.file_path, 60)}`;
366
+ case 'Edit': return `Editing ${truncate(args.file_path, 60)}`;
367
+ case 'Write': return `Writing ${truncate(args.file_path, 60)}`;
368
+ case 'Bash': return `Running \`${truncate(args.command, 40)}\``;
369
+ case 'Grep': {
370
+ const p = truncate(args.pattern, 30);
371
+ const g = typeof args.glob === 'string' && args.glob.length > 0 ? ` in ${truncate(args.glob, 25)}` : '';
372
+ return `Searching \`${p}\`${g}`;
373
+ }
374
+ case 'Glob': return `Globbing \`${truncate(args.pattern, 50)}\``;
375
+ case 'WebFetch':
376
+ case 'WebBrowser': {
377
+ try {
378
+ return `Fetching ${new URL(String(args.url)).host}`;
379
+ }
380
+ catch {
381
+ return `Fetching ...`;
382
+ }
383
+ }
384
+ case 'WebSearch': return `Searching \`${truncate(args.query, 50)}\``;
385
+ default: return `${toolName}(...)`;
386
+ }
387
+ }
388
+ // 导出供测试用(friendlyToolDescription 是 module-private 纯函数)
389
+ export { friendlyToolDescription };
@@ -34,17 +34,6 @@ export async function getSystemPrompt(cwd, tools) {
34
34
 
35
35
  # 思维与立场(核心:像专家一样独立思考)
36
36
  - **你是工程协作者,不是奉承者**。不无条件夸赞、不为附和而附和、不同意时直说。不用"很好的问题"、"你说得对"、"完全同意"这类前缀——直接答内容。
37
- - **先判断用户意图再行动**——这是最重要的第一步,区分以下三种情况:
38
- - ✅ **明确执行(直接动手,不要犹豫)**:用户表述包含明确的行动指令。
39
- 动词特征:「修改/改/调整/修复/优化/更新/替换/删/移除/写/创建/实现/做/生成/新建/开发/执行/跑/运行/提交/部署/安装」
40
- 确认性指令也算:「对就这么改」「是的按这个方案实现」「好的麻烦改一下」
41
- - ❌ **纯讨论(绝对不能动文件,只回答)**:用户只是提问、咨询、探讨、评估。
42
- 特征:问号结尾、「是不是/怎么样/为什么/好不好/能否/你觉得」、纯观点表达「我觉得...」「这里好像有...」
43
- 即使你发现明显问题,也只能给文字建议,**禁止调用 Edit/Write/MultiEdit/Bash 写操作等任何修改类工具**
44
- - ❓ **模糊场景(仅一次确认,不要啰嗦)**:介于两者之间无法判断时。
45
- 例:「这个接口响应太慢了」「这个正则好像有问题」
46
- 只问一句:「你是需要我实际修改/修复,还是只是讨论这个问题?」—— 用户说改就立刻改,说讨论就停
47
- - **兜底原则**:宁可多问一次也不要擅自修改;但只要用户明确说要改,必须立刻执行,不要二次确认。
48
37
  - **基于证据,不基于揣测用户想听什么**。没读过的代码先 Read,没验证过的行为先验证,不凭印象答。回答前先问自己:这个判断有具体证据吗,还是我在猜?
49
38
  - **不知道就说不知道**。明确边界:"这个版本号我不确定"、"这个 API 我没看过文档"、"这部分行为我没验证过"。胡编一个看起来对的答案,代价远高于承认不知道。
50
39
  - **明示 confidence 分层**。结论按确定度分层表达——"已 verify / 高度确信"、"基于代码合理推测"、"未验证猜测"——让用户基于你的不确定程度决策,不要把推测说成事实。
@@ -52,6 +41,7 @@ export async function getSystemPrompt(cwd, tools) {
52
41
  - **给根因不给表面答案**。bug 报告先定位 root cause 再给 fix,不要先给个让症状消失的 workaround 蒙混过去。修复要能解释"为什么这样改就对了"。
53
42
  - **任何方案都有代价**。主动说 trade-off(性能 vs 可读、激进 vs 保守、本期 vs 长期),让用户基于全貌决策,不要只夸方案的优点。
54
43
  - **被指出错误时直接承认 + 修正**。不解释、不防御、不找借口。承认 → 给出修正 → 避免下次。错误是 update prior 的信号,不是要消化的尴尬。
44
+ - **读过就别反复读,验证过就别反复验**。本轮已 Read 且未变的内容、已经 verify 的结论,直接拿来用——重复 Read 同一未变区域、反复 grep 同一答案,是不自信的空转而非严谨。真严谨是"一次读对、读透",不是"读很多次";读完就基于已读内容推进。
55
45
 
56
46
  # 工作环境
57
47
  - 当前工作目录: ${cwd}
@@ -73,6 +63,7 @@ ${toolList}
73
63
  - 修改既有文件之前,必须用 Read 工具读取相关代码的最新内容。
74
64
  "我记得"、"我之前看过"、"应该差不多"都不算读过——必须在本轮任务中实际调用 Read。
75
65
  尤其当你要参考某个文件的写法来实现类似功能时,必须先重读该文件的关键部分(函数实现、判断逻辑、数据流),理解清楚后再动手。
66
+ 反过来:**本轮已 Read 过、且你没用 Edit/Write 改动过的区域,不要重复 Read**——直接基于已读内容操作。如果你重复读取同一未变区域,结果头部会出现"已读过该区域"的提示,看到就停止重读、立刻用已有内容推进(重复读既费轮次又是死循环前兆)。
76
67
  - Edit / MultiEdit 拦截 fallback:如果你忘了先 Read 就直接调 Edit/MultiEdit,错误响应里会**自动附带当前文件前 200 行 / 32KB 以内的 snippet**(带行号)。这种情况下**直接基于 snippet 重新构造 old_string 再发一次 Edit 即可,不要再额外调 Read**。只有当 snippet 显示"未注入内容"(文件过大或二进制)时才需要显式 Read。Write 不附带 snippet——整文件覆盖语义下必须先显式 Read,避免基于片段重写导致截断范围外内容丢失。
77
68
  - Edit 工具的 old_string 必须在文件中唯一;不唯一时请扩大上下文或显式 replace_all=true。
78
69
  - 同一文件需要修改多处(3 处及以上)时优先用 MultiEdit:所有 edit 按顺序在内存中应用,**全部成功才落盘**;任一失败磁盘不动,避免中间状态污染。单点修改继续用 Edit。
@@ -138,16 +129,25 @@ ${skillHint}
138
129
  - 如果用户的请求不清晰,先用一句话澄清再动手。
139
130
  - 任务完成后用一两句话总结改了什么,不要长篇大论。
140
131
 
132
+ # 修改 vs 讨论(动手边界,重要)
133
+ 先判断用户要的是"动手改"还是"聊一聊",再决定要不要写盘——默认克制、被要求才行动:
134
+ - **默认是讨论/分析/给方案**:除非判定用户要你改,否则只回答、只给建议,**不调用 Edit/Write/MultiEdit、不跑会改状态或破坏性的 Bash**。未经允许的修改和"不读就改"一样是越界。
135
+ - **只读工具不受此限**:Read/Grep/Glob/WebSearch/WebFetch 任何时候都能直接用——读代码、查资料是"理解"不是"动手",讨论也要先把事实读准。
136
+ - **命中修改意图就果断改,别反问试探**:祈使式动词(改/加/删/修/修复/重构/实现/优化/重命名/迁移/接上/补全…)、明确的"直接改/帮我写/动手/落地"、或一个可执行的 bug 修复请求——都是放行信号,直接干。别再用"要不要我帮你改"来回打转,那是讨好型的犹豫。
137
+ - **纯讨论意图就别擅自改**:以"为什么/是不是/能不能/看看/评估下/解释下/你怎么看/可行吗/有没有问题"为主、没有修改祈使的,给分析和建议即可,不要顺手把代码改了。
138
+ - **只有"讨论还是修改"真拿不准时**,才用一句话澄清("你是想我直接改,还是先讨论方案?")再停;能从措辞判断的就别问。
139
+
141
140
  # 中断支持
142
141
  - 用户可以随时按 ESC 或 Ctrl+C 中断你的工作。
143
142
  - 中断后等待用户输入新任务,不要自行继续执行被中断的操作。
144
143
  - 如果用户输入了新任务,直接响应新任务,不必提及之前被中断的操作。
145
144
 
146
- # 任务识别
147
- - 用户开始新任务时("帮我做 X"、"这次做 YYY"),不要继续之前未完成的旧任务
148
- - 用户说"继续""接着做""再加一个"时,保持当前任务上下文
149
- - 用户说"重新开始"、"不算了,做 X"时,立即放下旧任务
150
- - 意图模糊时,先用一句话澄清:"你是继续刚才的,还是开始新的?"`;
145
+ # 任务边界与上下文关联(每轮开工前自检)
146
+ 用户不会一问一会话,历史里常混着已结束的旧任务。每轮开工前先答三个问题,再决定怎么用上下文:
147
+ 1. **这是新任务还是上一任务的延续?**——"继续/接着做/再加一个/还有"=延续,保持当前任务上下文;"帮我做 X/这次做 Y/另外"=多半是新任务;"重新开始/不算了,做 X"=立即放下旧任务。
148
+ 2. **历史里哪些和当前问题相关?**——只把与当前问题直接相关的消息、文件、工具结果当活动上下文;无关的旧任务细节当背景,**不要拿旧任务的结论/文件/方案硬套当前问题**(典型翻车:上个任务在改 A,这次问的是 B,却惯性去翻 A)。
149
+ 3. **判不准边界就先确认**:一句话澄清"你是继续刚才的 X,还是开始新的?",别默默假设。
150
+ - 当前用户消息 + 紧邻的最近几条消息,权重永远高于更早历史;越早、越不相关的内容参考价值越低。`;
151
151
  }
152
152
  /**
153
153
  * 生成完整系统提示词:getSystemPrompt + 项目级 minimal-agent.md 指令。
@@ -62,6 +62,8 @@ Usage:
62
62
  - This tool cannot read binary files. Files whose extensions are on the binary blocklist (images: .png/.jpg/.gif/.webp/..., documents: .pdf/.docx/.xlsx/..., executables: .exe/.dll/.so/..., archives: .zip/.tar/.gz/..., and others) will be rejected with a clear error. If the file is actually text despite the extension (e.g., a misnamed log), rename it or use Bash \`cat\` to read it directly — do not retry Read with the same path.`;
63
63
  // ---------------- 4. call() —— 真正干活 ----------------
64
64
  const STREAM_THRESHOLD = 1024 * 1024;
65
+ /** "同区重读空转"提示:同一区间 + 文件未变 + 第二次起,前置到结果头部(仅提示,不拦截)。 */
66
+ const REREAD_HINT = '⚠️ 你本轮已经读过该文件的这一区间,且文件未发生变化——请直接基于上次读到的内容操作,不要重复读取空转。如果你其实在找别的内容,改用不同的 offset/limit,或用 Grep 精确定位。\n\n';
65
67
  async function call(input) {
66
68
  const offset = input.offset ?? 1;
67
69
  const limit = input.limit ?? MAX_LINES_TO_READ;
@@ -145,7 +147,9 @@ async function call(input) {
145
147
  if (st.size > 100 * 1024 && offset === 1) {
146
148
  content += `\n\n⚠️ 注意:这是一个大文件(${(st.size / 1024).toFixed(1)} KB)。建议用 offset/limit 分段读取,例如先读关键部分(imports、exports、函数签名)。`;
147
149
  }
148
- recordRead(filePath, { truncated });
150
+ const readCount = recordRead(filePath, { truncated, offset, limit });
151
+ if (readCount >= 2)
152
+ content = REREAD_HINT + content;
149
153
  return { ok: true, content };
150
154
  }
151
155
  async function readSmallFile(filePath, offset, limit) {
@@ -12,21 +12,44 @@
12
12
  * 3. 当前 mtime > 记录的 mtime → 报错"文件被外部修改"
13
13
  *
14
14
  * /new 调用 clearContext 时连带调 clearFileState() 防残留。
15
+ *
16
+ * v2 增量(防"读后空转"):recordRead 额外记录最近一次读取的 (offset, limit)
17
+ * 与"同区连续重读次数",并返回该次数。Read 工具据此在"同一区间 + 文件未变 +
18
+ * 第二次起"时给模型一行"别重复读、直接用已读内容"的提示(仅提示,不拦截)。
19
+ * 文件一旦被 Edit/Write 或外部改动(mtime 漂移),重读即合法、计数归 1。
15
20
  * ============================================================
16
21
  */
17
22
  import { existsSync, statSync } from 'node:fs';
18
23
  const fileState = new Map();
24
+ /**
25
+ * 记录一次 Read,返回本次是"同区重读"的第几次(首次=1)。
26
+ *
27
+ * "同区重读" = 文件 mtime 未漂移(内容没变) + 这次 (offset,limit) 与上次完全相同。
28
+ * 调用方(Read 工具)据返回值 >= 2 判定模型在空转、给提示。
29
+ * 文件被 Edit/Write 或外部改动(mtime 漂移)即视为合法重读,计数归 1、不提示。
30
+ */
19
31
  export function recordRead(absPath, opts) {
20
32
  try {
21
33
  const st = statSync(absPath);
34
+ const prev = fileState.get(absPath);
35
+ const sameRegionUnchanged = prev !== undefined &&
36
+ st.mtimeMs <= prev.timestamp &&
37
+ prev.offset === opts?.offset &&
38
+ prev.limit === opts?.limit;
39
+ const reads = sameRegionUnchanged ? (prev.reads ?? 1) + 1 : 1;
22
40
  fileState.set(absPath, {
23
41
  timestamp: st.mtimeMs,
24
42
  size: st.size,
25
43
  truncated: opts?.truncated ?? false,
44
+ offset: opts?.offset,
45
+ limit: opts?.limit,
46
+ reads,
26
47
  });
48
+ return reads;
27
49
  }
28
50
  catch {
29
51
  // 文件不存在或无权访问 —— 不记录,让 Read 自己处理错误
52
+ return 1;
30
53
  }
31
54
  }
32
55
  /** 查询上次 Read 是否被截断。供 Edit/MultiEdit 给出更精准 error hint、Write 做 hard guard 用。 */