minimal-agent 0.6.1 → 0.6.3

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.
@@ -3,7 +3,7 @@
3
3
  * src/context/reactiveCompact.ts —— 反应式压缩(错误自救)
4
4
  * ------------------------------------------------------------
5
5
  * 对齐 kakadeai 主项目 services/compact/reactiveCompact.ts:
6
- * 当 API 返回 "prompt too long" 类错误时,自动触发一次压缩重试。
6
+ * 当 API 返回 "prompt too long" 类错误时,自动触发压缩重试。
7
7
  *
8
8
  * 典型场景:
9
9
  * 用户灌了一大段上下文 → 调 LLM → 返回 400 prompt_too_long
@@ -11,27 +11,39 @@
11
11
  * → 摘要失败再用 snipCompact 砍头兜底
12
12
  * → 把新上下文交还给调用方,调用方重试一次 chat()
13
13
  *
14
- * 防爆约束:
15
- * 每个 session 最多自救一次(attemptedThisSession 标志位)。
16
- * 用户 /new 重启会话后才能再次自救。这避免"压缩→还是太长→再压缩"
17
- * 的死循环。
14
+ * 防爆约束(circuit breaker):
15
+ * 连续自救失败 MAX_CONSECUTIVE_REACTIVE_FAILURES 次才熔断。
16
+ * 任意一次自救成功(LLM 压缩或 snip 兜底)→ 计数清零,
17
+ * 允许后续再次触发。行为:压缩成功→继续→再溢出→再压→正常往复。
18
+ * 用户 /new 重启会话后 consecutiveFailures 也归零。
18
19
  * ============================================================
19
20
  */
20
21
  import { forceCompact } from './compact.js';
21
22
  import { snipCompactIfNeeded } from './snipCompact.js';
22
23
  import { countMessagesTokens } from './tokenCounter.js';
23
24
  export function createReactiveCompactState() {
24
- return { attempted: false };
25
+ return { consecutiveFailures: 0 };
25
26
  }
27
+ /** 连续失败几次触发熔断,拒绝继续自救 */
28
+ export const MAX_CONSECUTIVE_REACTIVE_FAILURES = 3;
26
29
  const defaultState = createReactiveCompactState();
27
30
  /** /new 时调用,允许下一个 session 再次自救 */
28
31
  export function resetReactiveCompactState(state = defaultState) {
29
- state.attempted = false;
32
+ state.consecutiveFailures = 0;
30
33
  }
31
- /** 测试 / 调试用:查询当前是否已尝试 */
34
+ /**
35
+ * 测试 / 调试用:查询 circuit breaker 是否已熔断(连续失败达上限)。
36
+ *
37
+ * 原签名 hasAttemptedReactiveCompact 在新语义下映射为「电路是否已断开」:
38
+ * 单次成功就清零,只有连续失败 ≥ MAX_CONSECUTIVE_REACTIVE_FAILURES 才返回 true。
39
+ * 效果等价于原先的 attempted 旗标语义的"超集"(原先=1次,新=N次)。
40
+ * 别名 isReactiveCircuitOpen 供新代码使用,两者完全等价。
41
+ */
32
42
  export function hasAttemptedReactiveCompact(state = defaultState) {
33
- return state.attempted;
43
+ return state.consecutiveFailures >= MAX_CONSECUTIVE_REACTIVE_FAILURES;
34
44
  }
45
+ /** hasAttemptedReactiveCompact 的语义化别名 */
46
+ export const isReactiveCircuitOpen = hasAttemptedReactiveCompact;
35
47
  // ==================== 错误识别 ====================
36
48
  /**
37
49
  * 判断一个错误是否是"提示词太长"类错误。
@@ -63,29 +75,37 @@ function errorMessage(error) {
63
75
  return String(error ?? '');
64
76
  }
65
77
  /**
66
- * 如果当前错误是 prompt_too_long 且本 session 未尝试过自救,
78
+ * 如果当前错误是 prompt_too_long circuit breaker 未熔断,
67
79
  * 执行一次"先 LLM 压缩、失败兜底 snip"的恢复流程。
68
80
  *
81
+ * circuit breaker 规则:
82
+ * - 任意成功(LLM 压缩 or snip 兜底)→ consecutiveFailures 清零
83
+ * - 两个步骤都失败 → consecutiveFailures +1
84
+ * - consecutiveFailures ≥ MAX_CONSECUTIVE_REACTIVE_FAILURES → 熔断拒绝
85
+ *
69
86
  * @param messages 当前历史(不修改)
70
87
  * @param provider 当前 provider(用于 LLM 压缩)
71
88
  * @param error 刚刚抛出的错误
89
+ * @param state 可选的独立状态(默认使用进程级单例)
72
90
  */
73
91
  export async function reactiveCompactIfApplicable(messages, provider, error, state = defaultState) {
92
+ // 非 prompt_too_long 错误:直接短路,不消耗计数
74
93
  if (!isPromptTooLongError(error)) {
75
94
  return { recovered: false, messages, reason: 'not a prompt-too-long error' };
76
95
  }
77
- if (state.attempted) {
96
+ // 电路已熔断:连续失败达上限,拒绝继续
97
+ if (state.consecutiveFailures >= MAX_CONSECUTIVE_REACTIVE_FAILURES) {
78
98
  return {
79
99
  recovered: false,
80
100
  messages,
81
- reason: 'already attempted this session — use /new or /compact manually',
101
+ reason: '反应式压缩已熔断(连续失败达上限)——请 /new 或手动 /compact',
82
102
  };
83
103
  }
84
- // 占位:即使下面失败也算"用过一次",防止反复触发
85
- state.attempted = true;
86
104
  // Step 1: 先试 LLM 全量压缩
87
105
  try {
88
106
  const r = await forceCompact(messages, provider);
107
+ // 救活成功 → 清零计数,让后续继续可用
108
+ state.consecutiveFailures = 0;
89
109
  return {
90
110
  recovered: true,
91
111
  messages: r.messages,
@@ -94,16 +114,15 @@ export async function reactiveCompactIfApplicable(messages, provider, error, sta
94
114
  after: r.after,
95
115
  };
96
116
  }
97
- catch (compactErr) {
117
+ catch {
98
118
  // 压缩失败 → 走 snip 兜底
99
119
  }
100
120
  // Step 2: snip 兜底(更激进 40%)
101
121
  const beforeSnip = countMessagesTokens(messages);
102
- const snipped = snipCompactIfNeeded(messages, {
103
- force: true,
104
- snipPercent: 0.4,
105
- });
122
+ const snipped = snipCompactIfNeeded(messages, { force: true, snipPercent: 0.4 });
106
123
  if (snipped.messagesRemoved > 0) {
124
+ // snip 也算救活 → 清零计数
125
+ state.consecutiveFailures = 0;
107
126
  const afterSnip = countMessagesTokens(snipped.messages);
108
127
  return {
109
128
  recovered: true,
@@ -113,6 +132,8 @@ export async function reactiveCompactIfApplicable(messages, provider, error, sta
113
132
  after: afterSnip,
114
133
  };
115
134
  }
135
+ // 两步都没救活 → 失败计数 +1
136
+ state.consecutiveFailures++;
116
137
  return {
117
138
  recovered: false,
118
139
  messages,
@@ -0,0 +1,66 @@
1
+ /**
2
+ * ============================================================
3
+ * src/context/recentDirs.ts —— 最近工作目录注册表(Web 侧栏用)
4
+ * ------------------------------------------------------------
5
+ * 做的事:
6
+ * 维护 ~/.minimal-agent/recent-dirs.json —— 一个"用过哪些工作目录"的列表。
7
+ * 每个工作目录 = 一个会话(项目"一 cwd 一 session"约束),Web 前端侧栏据此
8
+ * 列出会话;每条记录顺带存该目录的会话文件路径,让前端不必复刻哈希命名规则
9
+ * 即可定位历史。
10
+ *
11
+ * 谁写:stream-json 模式每轮开始时 recordRecentDir(cwd, 首条用户消息)。
12
+ * 谁读:webchat 服务端直接 JSON.parse 本文件(不 import 后端业务模块)。
13
+ *
14
+ * 抉择:读失败返回 [];写失败静默 —— 注册表只是 UI 便利,绝不该影响对话本身。
15
+ * ============================================================
16
+ */
17
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
18
+ import { homedir } from 'node:os';
19
+ import { basename, dirname, join, resolve } from 'node:path';
20
+ import { sessionFileFor } from './sessionPath.js';
21
+ /** 注册表上限:只留最近 N 个,防文件无限增长。 */
22
+ const MAX_ENTRIES = 50;
23
+ function registryPath() {
24
+ return join(homedir(), '.minimal-agent', 'recent-dirs.json');
25
+ }
26
+ /** 读注册表,按最近使用倒序;任何异常返回 []。 */
27
+ export async function listRecentDirs() {
28
+ try {
29
+ const raw = await readFile(registryPath(), 'utf8');
30
+ const data = JSON.parse(raw);
31
+ if (!Array.isArray(data))
32
+ return [];
33
+ return data
34
+ .filter((e) => e && typeof e.path === 'string' && typeof e.sessionFile === 'string')
35
+ .sort((a, b) => b.lastUsedAt - a.lastUsedAt);
36
+ }
37
+ catch {
38
+ return [];
39
+ }
40
+ }
41
+ /**
42
+ * 记录/更新一个工作目录。upsert by path:已存在则更新 lastUsedAt(title 给了才覆盖,
43
+ * 否则保留旧 title),不存在则插入。写失败静默。
44
+ */
45
+ export async function recordRecentDir(cwd, title) {
46
+ try {
47
+ const abs = resolve(cwd);
48
+ const list = await listRecentDirs();
49
+ const prev = list.find((e) => e.path === abs);
50
+ const rest = list.filter((e) => e.path !== abs);
51
+ const entry = {
52
+ path: abs,
53
+ name: basename(abs) || abs,
54
+ lastUsedAt: Date.now(),
55
+ title: title ? title.slice(0, 80) : prev?.title,
56
+ sessionFile: sessionFileFor(abs),
57
+ };
58
+ const next = [entry, ...rest].slice(0, MAX_ENTRIES);
59
+ const file = registryPath();
60
+ await mkdir(dirname(file), { recursive: true });
61
+ await writeFile(file, JSON.stringify(next, null, 2), 'utf8');
62
+ }
63
+ catch {
64
+ // 静默:注册失败不影响对话
65
+ }
66
+ }
@@ -39,6 +39,29 @@ export function countTextTokens(text) {
39
39
  }
40
40
  return Math.ceil(asciiChars / 4) + nonAsciiChars;
41
41
  }
42
+ /**
43
+ * 估算「工具 schema」本身占的 token。每轮请求都会把 ALL_TOOLS 的
44
+ * name + description + parameters JSON 发给 LLM,但 countMessagesTokens 只数历史消息、
45
+ * 漏了这部分 → 系统性低估。自动压缩判定阈值时必须把它加上才准。
46
+ *
47
+ * 每个工具的 token 组成:
48
+ * - 8 token 固定开销(type/function 包裹等结构体)
49
+ * - name 字段的文本 token
50
+ * - description 字段的文本 token(支持字符串或函数两种形式)
51
+ * - parameters JSON 序列化后的文本 token
52
+ */
53
+ export function estimateToolsTokens(tools) {
54
+ let total = 0;
55
+ for (const t of tools) {
56
+ total += 8; // 每个工具的结构固定开销(type/function 包裹)
57
+ total += countTextTokens(t.name);
58
+ // description 可以是静态字符串,也可以是注入运行时信息的函数
59
+ const desc = typeof t.description === 'function' ? t.description() : t.description;
60
+ total += countTextTokens(desc);
61
+ total += countTextTokens(JSON.stringify(t.parameters));
62
+ }
63
+ return total;
64
+ }
42
65
  /** 估算整段历史的 token 数(包含 role / 工具调用结构的开销) */
43
66
  export function countMessagesTokens(messages) {
44
67
  let total = 0;
package/src/llm/client.js CHANGED
@@ -59,11 +59,16 @@ export async function* chat(args) {
59
59
  return rest;
60
60
  });
61
61
  // 3. 构造请求体
62
+ // 逃生开关:极个别 provider 不认 stream_options 会 400,可用 MINIMAL_AGENT_DISABLE_USAGE=1 关掉。
63
+ const includeUsage = process.env.MINIMAL_AGENT_DISABLE_USAGE !== '1';
62
64
  const body = {
63
65
  model: provider.model,
64
66
  messages: cleanedMessages,
65
67
  tools: openaiTools.length > 0 ? openaiTools : undefined,
66
68
  stream: true,
69
+ // stream_options.include_usage:让 provider 在流末附带真实 prompt_tokens,
70
+ // 供上层自动压缩判断用;provider 不支持时 usagePromptTokens 保持 undefined,上层 fallback 本地估算。
71
+ ...(includeUsage ? { stream_options: { include_usage: true } } : {}),
67
72
  // tool_choice: 'auto' 是默认值,可不写;某些 provider 必须显式
68
73
  tool_choice: openaiTools.length > 0 ? 'auto' : undefined,
69
74
  // ADR-05: structured output;缺省 undefined 不出现在 body(JSON.stringify 自动剥离)
@@ -95,6 +100,8 @@ export async function* chat(args) {
95
100
  let buffer = '';
96
101
  /** 累积的 stop_reason,最后 yield done 时用 */
97
102
  let stopReason = 'unknown';
103
+ /** 真实 prompt_tokens(从 usage chunk 读取);provider 不返回时保持 undefined */
104
+ let usagePromptTokens;
98
105
  try {
99
106
  while (true) {
100
107
  // 每次 read 前检查 abort:用户按 ESC/Ctrl+C 时 signal.aborted 为 true。
@@ -121,8 +128,8 @@ export async function* chat(args) {
121
128
  continue;
122
129
  const dataStr = line.slice(5).trim(); // 去掉 "data:" 前缀
123
130
  if (dataStr === '[DONE]') {
124
- // 流结束标记
125
- yield { type: 'done', stopReason };
131
+ // 流结束标记;带出真实 prompt_tokens(provider 不返回时为 undefined,上层 fallback 本地估算)
132
+ yield { type: 'done', stopReason, promptTokens: usagePromptTokens };
126
133
  return;
127
134
  }
128
135
  // 解析一个 chunk JSON
@@ -134,6 +141,12 @@ export async function* chat(args) {
134
141
  // 罕见:某些 provider 会发空行或心跳,忽略
135
142
  continue;
136
143
  }
144
+ // usage chunk:include_usage 开启时,provider 通常在流末单独发一帧,
145
+ // 此帧 choices 为空(没有 delta),必须在 `if (!delta) continue` 之前读取,
146
+ // 否则会被 continue 跳过,永远拿不到真实 prompt_tokens。
147
+ if (chunk.usage && typeof chunk.usage.prompt_tokens === 'number') {
148
+ usagePromptTokens = chunk.usage.prompt_tokens;
149
+ }
137
150
  const delta = chunk.choices?.[0]?.delta;
138
151
  const finish = chunk.choices?.[0]?.finish_reason;
139
152
  if (finish) {
@@ -189,8 +202,8 @@ export async function* chat(args) {
189
202
  }
190
203
  }
191
204
  }
192
- // 如果流没显式发 [DONE],也补一个 done
193
- yield { type: 'done', stopReason };
205
+ // 如果流没显式发 [DONE],也补一个 done;同样带出 promptTokens
206
+ yield { type: 'done', stopReason, promptTokens: usagePromptTokens };
194
207
  }
195
208
  finally {
196
209
  reader.releaseLock?.();
package/src/loop.js CHANGED
@@ -30,8 +30,16 @@ import crypto from 'node:crypto';
30
30
  import { autoCompactIfNeeded } from './context/compact.js';
31
31
  import { microCompact, incrementTurn, expireOldEntries } from './context/microCompactLite.js';
32
32
  import { isPromptTooLongError, reactiveCompactIfApplicable, } from './context/reactiveCompact.js';
33
+ import { snipCompactIfNeeded } from './context/snipCompact.js';
34
+ import { countMessagesTokens, estimateToolsTokens } from './context/tokenCounter.js';
33
35
  import { chat as defaultChat } from './llm/client.js';
34
- import { ALL_TOOLS, executeTool as defaultExecuteTool } from './tools/index.js';
36
+ import { ALL_TOOLS, executeTool as defaultExecuteTool, getToolByName } from './tools/index.js';
37
+ /**
38
+ * 工具 schema 的估算 token 数(ALL_TOOLS 固定,模块加载时算一次即可)。
39
+ * 每轮 LLM 请求都会带上整套工具 schema,但 countMessagesTokens 只数历史消息、
40
+ * 漏了这部分 → 系统性低估。autoCompact 判定阈值时加上它才准。
41
+ */
42
+ const TOOLS_SCHEMA_TOKENS = estimateToolsTokens(ALL_TOOLS);
35
43
  /**
36
44
  * 执行一次"用户输入 → 模型回答完成"的完整流程。
37
45
  *
@@ -54,9 +62,13 @@ export async function* runQuery(userInput, options) {
54
62
  // 立即通知 UI:用户消息已入栈,触发 bump 让 <Static> 马上把它 commit 进 scrollback,
55
63
  // 不必等整轮 assistant 落定(保证用户输入第一时间置顶,符合 T-A-O-R 展示顺序)。
56
64
  yield { type: 'user_message_committed' };
57
- // 反应式压缩自救:本 runQuery 只允许触发一次,避免压缩失败导致死循环。
58
- // 配合 reactiveCompact.ts 的 attemptedsession 级)双层防爆。
59
- let reactiveAttempted = false;
65
+ // 反应式压缩自救:本 runQuery 内最多触发 MAX_REACTIVE_PER_QUERY 次(防本轮死循环);
66
+ // runQuery 的连续失败由 reactiveCompact.ts 的 circuit breakerconsecutiveFailures)兜底。
67
+ let reactiveCount = 0;
68
+ const MAX_REACTIVE_PER_QUERY = 3;
69
+ // 上一轮 LLM 返回的真实 prompt token 数(done.promptTokens;provider 开 include_usage 才有)。
70
+ // 作为 autoCompact 的权威判据,校正本地估算的系统性低估;压缩后清空(历史已变,旧值失真)。
71
+ let lastPromptTokens;
60
72
  let turn = 0;
61
73
  while (turn < maxTurns) {
62
74
  turn++;
@@ -74,27 +86,45 @@ export async function* runQuery(userInput, options) {
74
86
  yield { type: 'error', error: '已被用户中断', code: 'aborted' };
75
87
  return;
76
88
  }
77
- // 2. 自动压缩
89
+ // 2. 自动压缩(判据 = max(上一轮真实 usage, 历史估算 + 工具 schema),
90
+ // 阈值 provider 无关、按 contextWindow × compactRatio 比例触发)
78
91
  try {
79
- const compact = await autoCompactIfNeeded(history, provider);
92
+ const compact = await autoCompactIfNeeded(history, provider, {
93
+ actualPromptTokens: lastPromptTokens,
94
+ toolsTokens: TOOLS_SCHEMA_TOKENS,
95
+ });
80
96
  if (compact.compacted) {
81
97
  yield { type: 'compact_start' };
82
98
  yield { type: 'stage_change', stage: 'compacting' };
83
99
  // in-place 替换 history(保持调用方持有的引用有效)
84
100
  history.length = 0;
85
101
  history.push(...compact.messages);
102
+ // 历史已压缩变小,旧的真实 usage 不再代表当前请求 → 清空,下一轮先用估算判据
103
+ lastPromptTokens = undefined;
86
104
  yield { type: 'compact_done', before: compact.before, after: compact.after };
87
105
  // 压缩完会回到 LLM 调用,stage 退回 thinking
88
106
  yield { type: 'stage_change', stage: 'thinking' };
89
107
  }
90
108
  }
91
109
  catch (e) {
92
- // 压缩失败不要让整个对话挂掉。
93
- // code='compact_failed':标记为「非致命」——注意此处 yield error 后**不 return**,
94
- // loop 继续往下跑(不压缩硬上)。`-p --output-format json` 据此只 stderr 警告、不退出。
110
+ // 压缩失败不要让整个对话挂掉:先 snip 砍最老消息释放空间再继续,
111
+ // 避免下一步 LLM 直接撞 prompt-too-long(code='compact_failed' 非致命,不 return)。
112
+ try {
113
+ const before = countMessagesTokens(history);
114
+ const snipped = snipCompactIfNeeded(history, { force: true, snipPercent: 0.3 });
115
+ if (snipped.messagesRemoved > 0) {
116
+ history.length = 0;
117
+ history.push(...snipped.messages);
118
+ lastPromptTokens = undefined;
119
+ yield { type: 'compact_done', before, after: countMessagesTokens(history) };
120
+ }
121
+ }
122
+ catch {
123
+ /* snip 也失败 → 真的只能硬上 */
124
+ }
95
125
  yield {
96
126
  type: 'error',
97
- error: `自动压缩失败(继续不压缩):${e.message}`,
127
+ error: `自动压缩失败(已尝试 snip 兜底后继续):${e.message}`,
98
128
  code: 'compact_failed',
99
129
  };
100
130
  }
@@ -111,6 +141,12 @@ export async function* runQuery(userInput, options) {
111
141
  const reasoningDetails = [];
112
142
  /** 首个 text_delta 时切到 streaming,再来不重复 yield */
113
143
  let stageStreamingYielded = false;
144
+ /**
145
+ * R2:本轮 LLM 流的终止原因(来自 client 的 done.stopReason)。
146
+ * 'length' = 被 provider 因 max_tokens 截断 —— 收尾时据此给 warning,
147
+ * 避免「半截答案」被静默当成正常 end_turn。
148
+ */
149
+ let lastFinishReason = 'unknown';
114
150
  // 进入 LLM 流前:等首 token / reasoning,本质是 thinking
115
151
  yield { type: 'stage_change', stage: 'thinking' };
116
152
  try {
@@ -145,7 +181,13 @@ export async function* runQuery(userInput, options) {
145
181
  reasoningDetails.push(...ev.items);
146
182
  }
147
183
  }
148
- // ev.type === 'done' 时无需处理:循环自然结束
184
+ else if (ev.type === 'done') {
185
+ // R2:记下流的终止原因。'length'(max_tokens 截断)会在本轮收尾时触发 warning。
186
+ lastFinishReason = ev.stopReason;
187
+ // 真实 prompt token(provider 开 include_usage 才有)→ 喂给下一轮 autoCompact 做权威判据
188
+ if (typeof ev.promptTokens === 'number')
189
+ lastPromptTokens = ev.promptTokens;
190
+ }
149
191
  }
150
192
  }
151
193
  catch (e) {
@@ -163,14 +205,17 @@ export async function* runQuery(userInput, options) {
163
205
  // prompt_too_long 反应式自救:压缩历史 → 同 turn 重发 LLM
164
206
  // LLM 看到压缩后的 9 段摘要 + 近期 verbatim 消息,能接着干活,
165
207
  // 不会丢失中途的工具调用上下文。
166
- if (isPromptTooLongError(e) && !reactiveAttempted) {
167
- reactiveAttempted = true;
208
+ if (isPromptTooLongError(e) && reactiveCount < MAX_REACTIVE_PER_QUERY) {
209
+ reactiveCount++;
168
210
  yield { type: 'compact_start' };
169
211
  yield { type: 'stage_change', stage: 'compacting' };
170
212
  const result = await reactiveCompactIfApplicable(history, provider, e, sessionState?.reactive);
171
- if (result.recovered) {
213
+ // 仅当确实压下去了(after < before)才重试;否则(没压动 / 未恢复)退回错误路径,杜绝死循环。
214
+ const shrank = (result.after ?? 0) < (result.before ?? 0);
215
+ if (result.recovered && shrank) {
172
216
  history.length = 0;
173
217
  history.push(...result.messages);
218
+ lastPromptTokens = undefined; // 历史已变小,旧真实 usage 失真
174
219
  yield {
175
220
  type: 'compact_done',
176
221
  before: result.before ?? 0,
@@ -181,7 +226,7 @@ export async function* runQuery(userInput, options) {
181
226
  turn--; // 不消耗 turn 配额,本轮重新走
182
227
  continue;
183
228
  }
184
- // reactive 也失败 → 退回正常错误路径
229
+ // reactive 没压动或未恢复 → 退回正常错误路径
185
230
  }
186
231
  // code='llm_error':终结性错误,stop_reason='error'
187
232
  yield {
@@ -191,6 +236,15 @@ export async function* runQuery(userInput, options) {
191
236
  };
192
237
  return;
193
238
  }
239
+ // S2 防御:OpenAI 协议保证 tool_call 的 index 连续;万一某 provider 跳号产生稀疏数组,
240
+ // 这里压实掉空洞,避免后续遍历 / 回填遇到 undefined(assistantMsg.tool_calls 与执行都用它)。
241
+ {
242
+ const dense = toolCallsByIndex.filter((tc) => tc != null);
243
+ if (dense.length !== toolCallsByIndex.length) {
244
+ toolCallsByIndex.length = 0;
245
+ toolCallsByIndex.push(...dense);
246
+ }
247
+ }
194
248
  const assistantMsg = {
195
249
  role: 'assistant',
196
250
  content: assistantText.length > 0 ? assistantText : null,
@@ -205,100 +259,112 @@ export async function* runQuery(userInput, options) {
205
259
  yield { type: 'assistant_message', message: assistantMsg };
206
260
  // 4. 没有工具调用 → 整轮交互结束
207
261
  if (toolCallsByIndex.length === 0) {
262
+ // R2:最终回答被 provider 因 max_tokens 截断(finish_reason='length')时,
263
+ // 不能静默当正常 end_turn 收尾 —— 否则 -p 宿主会拿到半截答案却以为成功。
264
+ // 走通用 warning(text/json 都落 stderr,不污染 json 那一行 stdout 契约)。
265
+ if (lastFinishReason === 'length') {
266
+ yield {
267
+ type: 'warning',
268
+ message: '⚠️ 模型回答可能被截断(finish_reason=length,触顶 max_tokens),本轮回答或不完整。' +
269
+ '可调大模型的 max_tokens 上限,或让模型分段输出。',
270
+ };
271
+ }
208
272
  yield { type: 'turn_done' };
209
273
  return;
210
274
  }
211
- // 5. 并行执行所有工具
275
+ // 5. 执行工具调用
212
276
  //
213
- // 设计:用 Promise.allSettled 启动 N 个 worker,配合一个简易事件队列
214
- // (queue + signalNew)把多个 worker 的 tool_start / tool_end 事件按
215
- // 真实完成顺序交错 yield 给 UI;但 history 里的 tool 消息严格按
216
- // toolCallsByIndex 索引顺序 push(OpenAI 协议要求 tool 消息和上一条
217
- // assistant 的 tool_calls 一一对应)。
277
+ // R1:按 isConcurrencySafe 决定并发策略 —— 工具早就声明了这个字段,此前却没有
278
+ // 任何执行路径消费它(全程无条件并行),这里让它真正生效:
279
+ // - 本轮工具「全部并发安全」(只读类 Read/Grep/Glob/WebSearch/WebFetch)且不止一个
280
+ // 并行:用事件队列把多个 worker tool_start/tool_end 按真实完成顺序交错 yield。
281
+ // - 只要有一个写类/未知工具(Edit/Write/MultiEdit/Bash/WebBrowser)→ 整轮串行:
282
+ // 彻底杜绝两个写操作并行落到同一文件导致的 lost-update / fileState TOCTOU。
283
+ // 两种路径下,history 里的 tool 消息都严格按 toolCallsByIndex 索引顺序回填
284
+ // (OpenAI 协议要求 tool 消息与上一条 assistant 的 tool_calls 一一对应)。
218
285
  yield { type: 'stage_change', stage: 'tool_executing' };
219
- const queue = [];
220
- let signalNew = null;
221
- const enqueue = (ev) => {
222
- queue.push(ev);
223
- signalNew?.();
224
- signalNew = null;
225
- };
226
- const workers = toolCallsByIndex.map(async (tc) => {
227
- enqueue({
228
- type: 'tool_start',
229
- toolCallId: tc.id,
230
- toolName: tc.function.name,
231
- argsPreview: previewArgs(tc.function.arguments),
232
- argsFriendly: friendlyToolDescription(tc.function.name, tc.function.arguments),
233
- });
286
+ // 执行单个 tool call → WorkerOutcome(内部吞掉 abort/异常,永不 reject)。
287
+ const runOne = async (tc) => {
234
288
  try {
235
289
  const result = await executeToolFn(tc.function.name, tc.function.arguments, signal);
236
- enqueue({
237
- type: 'tool_end',
238
- toolCallId: tc.id,
239
- toolName: tc.function.name,
240
- ok: result.ok,
241
- content: result.ok ? result.content : `Error: ${result.error}`,
242
- });
243
290
  return { tc, result };
244
291
  }
245
292
  catch (e) {
246
293
  if (e.name === 'AbortError') {
247
- enqueue({
248
- type: 'tool_end',
249
- toolCallId: tc.id,
250
- toolName: tc.function.name,
251
- ok: false,
252
- content: '(已中断)',
253
- });
254
294
  return { tc, result: { ok: false, error: 'aborted' } };
255
295
  }
256
- const msg = e.message;
257
- enqueue({
258
- type: 'tool_end',
259
- toolCallId: tc.id,
260
- toolName: tc.function.name,
261
- ok: false,
262
- content: msg,
263
- });
264
- return { tc, result: { ok: false, error: msg } };
296
+ return { tc, result: { ok: false, error: e.message } };
265
297
  }
298
+ };
299
+ const startEvent = (tc) => ({
300
+ type: 'tool_start',
301
+ toolCallId: tc.id,
302
+ toolName: tc.function.name,
303
+ argsPreview: previewArgs(tc.function.arguments),
304
+ argsFriendly: friendlyToolDescription(tc.function.name, tc.function.arguments),
305
+ });
306
+ const endEvent = (o) => ({
307
+ type: 'tool_end',
308
+ toolCallId: o.tc.id,
309
+ toolName: o.tc.function.name,
310
+ ok: o.result.ok,
311
+ content: o.result.ok
312
+ ? o.result.content
313
+ : o.result.error === 'aborted'
314
+ ? '(已中断)'
315
+ : `Error: ${o.result.error}`,
266
316
  });
267
- const allDone = Promise.allSettled(workers);
268
- let finished = false;
269
- allDone.then(() => {
270
- finished = true;
271
- signalNew?.();
272
- signalNew = null;
317
+ const allConcurrencySafe = toolCallsByIndex.every((tc) => {
318
+ const tool = getToolByName(tc.function.name);
319
+ return tool?.isConcurrencySafe === true;
273
320
  });
274
- while (!finished || queue.length > 0) {
275
- while (queue.length > 0)
276
- yield queue.shift();
277
- if (finished)
278
- break;
279
- await new Promise((r) => {
280
- signalNew = r;
321
+ let settled;
322
+ if (allConcurrencySafe && toolCallsByIndex.length > 1) {
323
+ // —— 并行:事件队列把各 worker 的 start/end 按真实完成顺序交错 yield 给 UI。
324
+ const queue = [];
325
+ let signalNew = null;
326
+ const enqueue = (ev) => {
327
+ queue.push(ev);
328
+ signalNew?.();
329
+ signalNew = null;
330
+ };
331
+ const workers = toolCallsByIndex.map(async (tc) => {
332
+ enqueue(startEvent(tc));
333
+ const o = await runOne(tc);
334
+ enqueue(endEvent(o));
335
+ return o;
281
336
  });
282
- }
283
- // 严格按 toolCallsByIndex 顺序 push tool 消息进 history(OpenAI 协议要求)
284
- const settled = await allDone;
285
- let anyAborted = false;
286
- for (let i = 0; i < toolCallsByIndex.length; i++) {
287
- const tc = toolCallsByIndex[i];
288
- const r = settled[i];
289
- if (r.status === 'rejected') {
290
- // 理论上 worker 已经 catch 了所有异常,这里只是防御
291
- const errMsg = r.reason?.message ?? 'unknown';
292
- history.push({
293
- role: 'tool',
294
- tool_call_id: tc.id,
295
- content: `Error: ${errMsg}`,
296
- timestamp: Date.now(),
297
- id: crypto.randomUUID(),
337
+ const allDone = Promise.all(workers);
338
+ let finished = false;
339
+ void allDone.then(() => {
340
+ finished = true;
341
+ signalNew?.();
342
+ signalNew = null;
343
+ });
344
+ while (!finished || queue.length > 0) {
345
+ while (queue.length > 0)
346
+ yield queue.shift();
347
+ if (finished)
348
+ break;
349
+ await new Promise((r) => {
350
+ signalNew = r;
298
351
  });
299
- continue;
300
352
  }
301
- const { result } = r.value;
353
+ settled = await allDone;
354
+ }
355
+ else {
356
+ // —— 串行:逐个执行(含写类工具,或只有一个工具)。事件直接顺序 yield,无需队列。
357
+ settled = [];
358
+ for (const tc of toolCallsByIndex) {
359
+ yield startEvent(tc);
360
+ const o = await runOne(tc);
361
+ yield endEvent(o);
362
+ settled.push(o);
363
+ }
364
+ }
365
+ // 严格按 toolCallsByIndex 顺序把 tool 消息回填 history(settled 已保持该顺序)。
366
+ let anyAborted = false;
367
+ for (const { tc, result } of settled) {
302
368
  if (!result.ok && result.error === 'aborted')
303
369
  anyAborted = true;
304
370
  const rawContent = result.ok ? result.content : `Error: ${result.error}`;
@@ -315,7 +381,7 @@ export async function* runQuery(userInput, options) {
315
381
  });
316
382
  }
317
383
  if (anyAborted || signal?.aborted) {
318
- // 任一工具抛 AbortError 时,外层 yield interrupted 并 return
384
+ // 工具被中断时,外层 yield interrupted 并 return
319
385
  yield { type: 'interrupted' };
320
386
  return;
321
387
  }
@@ -375,6 +441,10 @@ function friendlyToolDescription(toolName, rawArgsJson) {
375
441
  switch (toolName) {
376
442
  case 'Read': return `Reading ${truncate(args.file_path, 60)}`;
377
443
  case 'Edit': return `Editing ${truncate(args.file_path, 60)}`;
444
+ case 'MultiEdit': {
445
+ const n = Array.isArray(args.edits) ? args.edits.length : 0;
446
+ return `Editing ${truncate(args.file_path, 50)}${n ? ` (${n} 处)` : ''}`;
447
+ }
378
448
  case 'Write': return `Writing ${truncate(args.file_path, 60)}`;
379
449
  case 'Bash': return `Running \`${truncate(args.command, 40)}\``;
380
450
  case 'Grep': {