mingdao-harness 0.6.13 → 0.6.15

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.
@@ -17,6 +17,241 @@ import { mingdaoHome } from '../config.js';
17
17
  import { resolveApiKey, getStoredKey } from '../credentials.js';
18
18
  import { isLocalBaseUrl } from '../model-caps.js';
19
19
 
20
+ // ---------------------------------------------------------------------------
21
+ // 本地端点并发闸门(v0.6.15,登记 §3.59 四条待办之首)
22
+ //
23
+ // 真机证据(v0.6.14,macOS,`mtplx-qwen38-27b-optimized-quality` @ 8081):
24
+ // 长任务中途被引擎拒绝——
25
+ // insufficient memory: the request exceeded available GPU memory
26
+ // (sustained critical memory pressure during prefill; aborted before the allocator wall).
27
+ // The engine shed its caches and stays up; this request failed.
28
+ // 同一错误此前在 60091 的 35B 上也出现过 → **不是模型问题,是本机内存压力**;
29
+ // 观测到该回合此前有子代理在跑(13 轮 / 17 次调用)→ 父回合 + 子代理同时向**同一本地引擎**
30
+ // 发请求是内存压力的主要来源(两个大 prefill 各自要一份 KV cache)。
31
+ //
32
+ // 修法:对**同一 baseUrl** 的本地端点在途请求做**串行化/限量**,默认 1 并发。
33
+ // 覆盖三类来源(都必须过同一道闸门,否则等于没修):
34
+ // ① 父回合的 provider.chat;
35
+ // ② 子代理(同一 provider 实例/同一 baseUrl,天然共享本模块的进程级队列);
36
+ // ③ 端点预检(model-discovery.js 的探针也打同一个 /chat/completions,抢的是同一份内存)。
37
+ // 云端端点**不受影响**:`isLocalBaseUrl` 为假时直接放行(不排队、不记日志、不加延迟)。
38
+ //
39
+ // 三条硬约束:
40
+ // · **不许忙等**:Promise 队列 + 释放即移交名额;
41
+ // · **排队也有上限**:`config.localQueueTimeoutMs`(默认 30 分钟),超时给**可见错误**,
42
+ // 不会让第二个回合永远挂着(真机最怕的正是"看起来在跑、其实永远排不到");
43
+ // · **日志留痕**:入队时记一行「本地端点串行化:等待 N 个在途请求」,并回调 onWait
44
+ // 让 agent/WebUI 把「已串行化(防内存压力)」摆到进度行上。
45
+
46
+ /** 默认并发上限:本地引擎通常是单进程/单 slot,1 是最安全的默认值。 */
47
+ export const DEFAULT_LOCAL_CONCURRENCY = 1;
48
+ /** 默认排队等待上限:与本地 `timeout.totalMs`(30 分钟)同量级,避免死等。 */
49
+ export const DEFAULT_LOCAL_QUEUE_TIMEOUT_MS = 30 * 60 * 1000;
50
+
51
+ /**
52
+ * 归一化 baseUrl(去掉尾部斜杠):同一端点的不同写法必须命中同一条队列。
53
+ * @param {any} baseUrl
54
+ */
55
+ export function normalizeLocalBaseUrl(/** @type {any} */ baseUrl) {
56
+ return String(baseUrl || '').trim().replace(/\/+$/, '');
57
+ }
58
+
59
+ /**
60
+ * 本地端点并发上限(纯函数,便于断言/变异):`config.localConcurrency`。
61
+ * · 未配置 / 非法 / 负数 → 默认 1(fail-safe:宁可串行化,也不要再撞内存墙);
62
+ * · **0 → 不限**(显式退出串行化,用户明确知道自己在意延迟而不是内存);
63
+ * · 正整数 → 该值(上限 16,防手滑写成 999 等于没限制)。
64
+ * @param {any} cfg
65
+ */
66
+ export function localConcurrencyLimit(/** @type {any} */ cfg) {
67
+ const raw = cfg?.localConcurrency;
68
+ if (raw === undefined || raw === null || raw === '') return DEFAULT_LOCAL_CONCURRENCY;
69
+ const n = Number(raw);
70
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_LOCAL_CONCURRENCY;
71
+ return Math.min(16, Math.floor(n));
72
+ }
73
+
74
+ /**
75
+ * 排队等待上限(纯函数):`config.localQueueTimeoutMs`,未配置/非法 → 30 分钟。
76
+ * 0/负数不表示"无限等",而是回落默认(死等是这轮要消灭的东西)。
77
+ * @param {any} cfg
78
+ */
79
+ export function localQueueTimeoutMs(/** @type {any} */ cfg) {
80
+ const n = Number(cfg?.localQueueTimeoutMs);
81
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_LOCAL_QUEUE_TIMEOUT_MS;
82
+ return Math.min(n, 24 * 60 * 60 * 1000);
83
+ }
84
+
85
+ /** 一条给人看的入队日志(纯函数,日志/UI 共用同一份口径)。 @param {any} ev */
86
+ export function localQueueEventText(/** @type {any} */ ev) {
87
+ const endpoint = String(ev?.endpoint || ev?.baseUrl || '本地端点');
88
+ const active = Number(ev?.active) || 0;
89
+ const queued = Number(ev?.queued) || 0;
90
+ const limit = Number(ev?.limit) || DEFAULT_LOCAL_CONCURRENCY;
91
+ return (
92
+ `本地端点串行化:等待 ${active} 个在途请求(${endpoint},并发上限 ${limit}` +
93
+ `${queued > 1 ? `,排在第 ${queued} 位` : ''})——本地模型内存压力,避免父子回合同时 prefill`
94
+ );
95
+ }
96
+
97
+ /** @type {((ev: any) => void)|null} */
98
+ let queueObserver = null;
99
+ /**
100
+ * 注册串行化事件观察者(WebUI 启动时接到 srvlog;不注册则写 console.error)。
101
+ * @param {((ev: any) => void)|null} fn
102
+ */
103
+ export function setLocalQueueObserver(/** @type {any} */ fn) {
104
+ queueObserver = typeof fn === 'function' ? fn : null;
105
+ }
106
+
107
+ function emit(/** @type {any} */ ev) {
108
+ try {
109
+ if (queueObserver) queueObserver(ev);
110
+ else console.error(`[MingDao] ${ev.text}`);
111
+ } catch {
112
+ /* 日志失败不得影响请求 */
113
+ }
114
+ }
115
+
116
+ /** @type {Map<string, { baseUrl: string, limit: number, active: number, waiters: any[] }>} */
117
+ const localGates = new Map();
118
+
119
+ /** @param {string} baseUrl @param {number} limit */
120
+ function localGateFor(baseUrl, limit) {
121
+ let g = localGates.get(baseUrl);
122
+ if (!g) {
123
+ g = { baseUrl, limit, active: 0, waiters: [] };
124
+ localGates.set(baseUrl, g);
125
+ }
126
+ g.limit = limit;
127
+ return g;
128
+ }
129
+
130
+ /** 端点当前状态(纯快照,供预告/进度行/断言用;不参与调度)。 @param {any} baseUrl */
131
+ export function localGateSnapshot(/** @type {any} */ baseUrl) {
132
+ const key = normalizeLocalBaseUrl(baseUrl);
133
+ const g = localGates.get(key);
134
+ if (!g) return { baseUrl: key, active: 0, queued: 0, limit: DEFAULT_LOCAL_CONCURRENCY, known: false };
135
+ return { baseUrl: key, active: g.active, queued: g.waiters.length, limit: g.limit, known: true };
136
+ }
137
+
138
+ /** 该端点现在是否已有在途/排队请求(纯查询;云端恒 false)。 @param {any} baseUrl */
139
+ export function localGateBusy(/** @type {any} */ baseUrl) {
140
+ const snap = localGateSnapshot(baseUrl);
141
+ return Boolean(snap.known && (snap.active > 0 || snap.queued > 0));
142
+ }
143
+
144
+ /** 取得名额后的释放函数:把名额**直接移交**给下一个等待者(active 不变),否则 active--。 */
145
+ function makeLocalRelease(/** @type {any} */ g) {
146
+ let done = false;
147
+ return () => {
148
+ if (done) return;
149
+ done = true;
150
+ const next = g.waiters.shift();
151
+ if (next) {
152
+ // 名额移交:active 不减,等待者的 grant 负责 resolve(不改变 active)
153
+ next.grant();
154
+ return;
155
+ }
156
+ g.active = Math.max(0, g.active - 1);
157
+ if (g.active === 0 && g.waiters.length === 0 && localGates.get(g.baseUrl) === g) localGates.delete(g.baseUrl);
158
+ };
159
+ }
160
+
161
+ /**
162
+ * 取得一个本地端点执行名额(云端端点直接返回空释放函数)。
163
+ *
164
+ * 返回的 release **必须**在请求真正结束后调用(流读完、finally 里),否则后续请求会
165
+ * 永远排队——这正是"不能用忙等、也不许忘记释放"的接口约束。
166
+ *
167
+ * @param {any} baseUrl
168
+ * @param {{ cfg?: any, signal?: any, onWait?: (ev: any) => void, queueTimeoutMs?: number,
169
+ * endpoint?: string, label?: string }} [opts]
170
+ * @returns {Promise<() => void>}
171
+ */
172
+ export async function acquireLocalSlot(/** @type {any} */ baseUrl, opts = {}) {
173
+ const key = normalizeLocalBaseUrl(baseUrl);
174
+ // 云端端点不受影响:不排队、不记日志、不增加任何延迟(需求 1 的硬边界)。
175
+ if (!isLocalBaseUrl(key)) return () => {};
176
+ const limit = localConcurrencyLimit(opts.cfg);
177
+ if (limit === 0) return () => {}; // 显式退出串行化
178
+ const waitMs = Number(opts.queueTimeoutMs) > 0 ? Number(opts.queueTimeoutMs) : localQueueTimeoutMs(opts.cfg);
179
+ const g = localGateFor(key, limit);
180
+
181
+ if (g.active < g.limit) {
182
+ g.active += 1;
183
+ return makeLocalRelease(g);
184
+ }
185
+
186
+ // 需要排队:先留痕(日志/UI),再把自己挂到 Promise 队列上。
187
+ const endpoint = String(opts.endpoint || key);
188
+ const queued = g.waiters.length + 1;
189
+ const ev = /** @type {any} */ ({ type: 'wait', baseUrl: key, endpoint, active: g.active, queued, limit: g.limit, label: opts.label || '' });
190
+ ev.text = localQueueEventText(ev);
191
+ emit(ev);
192
+ try {
193
+ opts.onWait?.(ev);
194
+ } catch {}
195
+
196
+ return await new Promise((resolve, reject) => {
197
+ /** @type {any} */
198
+ const waiter = { settled: false, grant: null, timer: null, onAbort: null, signal: opts.signal || null };
199
+ const settle = (/** @type {boolean} */ ok, /** @type {any} */ value) => {
200
+ if (waiter.settled) return;
201
+ waiter.settled = true;
202
+ if (waiter.timer) clearTimeout(waiter.timer);
203
+ try {
204
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener('abort', waiter.onAbort);
205
+ } catch {}
206
+ const at = g.waiters.indexOf(waiter);
207
+ if (at >= 0) g.waiters.splice(at, 1);
208
+ if (ok) resolve(value);
209
+ else reject(value);
210
+ };
211
+ waiter.grant = () => {
212
+ if (waiter.settled) return;
213
+ waiter.settled = true;
214
+ if (waiter.timer) clearTimeout(waiter.timer);
215
+ try {
216
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener('abort', waiter.onAbort);
217
+ } catch {}
218
+ // 名额已从上一个持有者移交过来:active 保持不变
219
+ try {
220
+ opts.onWait?.({ ...ev, waiting: false, active: g.active, queued: Math.max(0, g.waiters.length), limit: g.limit });
221
+ } catch {}
222
+ resolve(makeLocalRelease(g));
223
+ };
224
+ waiter.timer = setTimeout(() => {
225
+ const err = /** @type {any} */ (
226
+ new Error(
227
+ `本地端点排队超时(等待 ${(waitMs / 1000).toFixed(1)}s,仍有 ${g.active} 个在途请求,并发上限 ${g.limit}):` +
228
+ `为避免内存压力已在本机端点串行化——可调大 config.localQueueTimeoutMs,或减少子代理/并行会话。`
229
+ )
230
+ );
231
+ err.code = 'LOCAL_QUEUE_TIMEOUT';
232
+ err.status = 0;
233
+ err.localEndpoint = key;
234
+ settle(false, err);
235
+ }, waitMs);
236
+ waiter.onAbort = () => settle(false, opts.signal?.reason ?? new Error('已中断(本地端点排队中)'));
237
+ if (opts.signal) {
238
+ if (opts.signal.aborted) {
239
+ waiter.onAbort();
240
+ return;
241
+ }
242
+ try {
243
+ opts.signal.addEventListener('abort', waiter.onAbort, { once: true });
244
+ } catch {}
245
+ }
246
+ g.waiters.push(waiter);
247
+ });
248
+ }
249
+
250
+ /** 仅供测试:清空全部队列(不影响已取得名额的释放函数)。 */
251
+ export function __resetLocalGates() {
252
+ localGates.clear();
253
+ }
254
+
20
255
  // v0.6.3(下游 Dify 工作流反馈):`customModels` 的条目其实是**两种东西**,此前混为一谈——
21
256
  // · **声明式端点**:写了 baseUrl / envKey / apiKey / headers 等传输字段 → 确实是"自定义
22
257
  // OpenAI 兼容端点",优先于内置预设(原行为,不变);
@@ -254,40 +489,58 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
254
489
  }
255
490
  const ac = new AbortController();
256
491
  currentAc = ac;
257
- let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时(首 token/流式空闲)
258
- // 首 token 等待:prefill 阶段无任何帧到达即断(覆盖长上下文慢 prefill)
259
- let firstTokenTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (setTimeout(() => {
260
- timedOut = true;
261
- ac.abort(new Error(`首 token 等待超限(${Math.round(firstTokenMs / 1000)}s,本地模型长上下文 prefill 可能很慢)——可调大 config.timeout.firstTokenMs,或拆分任务/压缩上下文`));
262
- }, firstTokenMs));
263
- // 流式空闲:有帧后 120s 无新帧即断;每收到一帧重置
264
- let idleTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (null);
265
- const armIdle = () => {
266
- if (idleTimer) clearTimeout(idleTimer);
267
- idleTimer = setTimeout(() => {
268
- timedOut = true;
269
- ac.abort(new Error(`流式响应空闲超限(${Math.round(streamIdleMs / 1000)}s 无新数据)`));
270
- }, streamIdleMs);
271
- };
272
- const onActivity = () => {
273
- sawFrame = true;
274
- if (firstTokenTimer) {
275
- clearTimeout(firstTokenTimer);
276
- firstTokenTimer = null;
277
- }
278
- armIdle();
279
- };
280
- // 转发外部信号(用户 Ctrl+C 中断),避免被内部超时信号覆盖
492
+ // 转发外部信号(用户 Ctrl+C 中断),避免被内部超时信号覆盖。
493
+ // 必须在**排队之前**挂上:否则用户中断时请求仍卡在本地端点队列里。
281
494
  const onUserAbort = () => ac.abort(opts.signal?.reason);
282
495
  if (opts.signal?.aborted) onUserAbort();
283
496
  else opts.signal?.addEventListener('abort', onUserAbort, { once: true });
497
+ let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时(首 token/流式空闲)
498
+ let firstTokenTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (null);
499
+ let idleTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (null);
284
500
  let leaving = true; // 本次 attempt 是否退出(成功/不再重试);将重试则置 false,总量计时器保留覆盖下一 attempt
285
501
  // v0.6.3(BUG-028):**收到过任何一帧就不再重试**。chat 是**非幂等**的 POST:
286
502
  // 上游一旦开始生成按量计费,重试等于再买一次(用户只看到一份回答、账本却计两次)。
287
503
  // 判据取"有没有帧到达"而不是"有没有正文"——usage-only/角色帧同样说明请求已被受理。
288
504
  // 代价如实说明:流式空闲超时(已开始输出)不再重试,失败以错误形式回到用户手里,而不是悄悄多花一份钱。
289
505
  let sawFrame = false;
506
+ let releaseLocal = /** @type {null | (() => void)} */ (null);
290
507
  try {
508
+ // v0.6.15(§3.59):**本地端点并发闸门**——同一 baseUrl 的父回合 / 子代理 / 预检
509
+ // 共用一条进程级 Promise 队列(默认 1 并发)。真机 8081:父子回合同时 prefill 时
510
+ // 引擎报 "insufficient memory … engine shed its caches and stays up"。
511
+ // 必须在**首 token 计时之前**排队:排队是内核自己的调度,不是"引擎迟迟不出首帧"。
512
+ // 云端端点由 acquireLocalSlot 内部直接放行(不排队、不记日志、不加延迟)。
513
+ releaseLocal = await acquireLocalSlot(pc.baseUrl, {
514
+ cfg,
515
+ signal: ac.signal,
516
+ onWait: opts.onQueueWait,
517
+ endpoint: pc.name || pc.baseUrl,
518
+ label: modelName,
519
+ });
520
+ // 排队期间总量计时器可能已经到点:它会 abort 本 attempt 的 ac(见 totalTimer),
521
+ // 排队等待因此以「请求总时长超限」拒绝(acquireLocalSlot 透传 signal.reason);
522
+ // 这里**不再写一份重复的复查**——循环头那一处是 BUG-029 的单一判据(§119 钉住)。
523
+ // 首 token 等待:prefill 阶段无任何帧到达即断(覆盖长上下文慢 prefill)
524
+ firstTokenTimer = setTimeout(() => {
525
+ timedOut = true;
526
+ ac.abort(new Error(`首 token 等待超限(${Math.round(firstTokenMs / 1000)}s,本地模型长上下文 prefill 可能很慢)——可调大 config.timeout.firstTokenMs,或拆分任务/压缩上下文`));
527
+ }, firstTokenMs);
528
+ // 流式空闲:有帧后 120s 无新帧即断;每收到一帧重置
529
+ const armIdle = () => {
530
+ if (idleTimer) clearTimeout(idleTimer);
531
+ idleTimer = setTimeout(() => {
532
+ timedOut = true;
533
+ ac.abort(new Error(`流式响应空闲超限(${Math.round(streamIdleMs / 1000)}s 无新数据)`));
534
+ }, streamIdleMs);
535
+ };
536
+ const onActivity = () => {
537
+ sawFrame = true;
538
+ if (firstTokenTimer) {
539
+ clearTimeout(firstTokenTimer);
540
+ firstTokenTimer = null;
541
+ }
542
+ armIdle();
543
+ };
291
544
  return await openaiChat({
292
545
  ...opts,
293
546
  baseUrl: pc.baseUrl,
@@ -299,7 +552,9 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
299
552
  } catch (err) {
300
553
  // 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试。
301
554
  // 总量超时(totalExpired)覆盖整个序列,超了直接抛不再重试。
302
- const transient = !totalExpired && !sawFrame && ((timedOut && !opts.signal?.aborted) || isTransient(err));
555
+ // 排队超时(LOCAL_QUEUE_TIMEOUT)**不得**重试:重试只会再排一次队、把等待翻倍。
556
+ const queueTimeout = (/** @type {any} */ (err))?.code === 'LOCAL_QUEUE_TIMEOUT';
557
+ const transient = !totalExpired && !queueTimeout && !sawFrame && ((timedOut && !opts.signal?.aborted) || isTransient(err));
303
558
  if (!transient || attempt >= retries) throw err;
304
559
  leaving = false; // 将重试:本次不退出,总量计时器继续覆盖下一 attempt
305
560
  attempt += 1;
@@ -315,6 +570,8 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
315
570
  if (firstTokenTimer) clearTimeout(firstTokenTimer);
316
571
  if (idleTimer) clearTimeout(idleTimer);
317
572
  opts.signal?.removeEventListener('abort', onUserAbort);
573
+ // 名额必须在这里释放(成功/失败/超时/中断都走 finally),否则队列会永久卡住。
574
+ try { releaseLocal?.(); } catch {}
318
575
  }
319
576
  }
320
577
  },
package/src/tools-flow.js CHANGED
@@ -17,6 +17,33 @@
17
17
  // 回填之所以拆成"序列化 → 正文"两个函数,而不是合成一个大函数,正是为了**保持原来的求值顺序**:
18
18
  // 原来 `JSON.stringify(result)` 发生在 `checkPostTool()` **之前**(约束检查抛错/慢的差异都被保留),
19
19
  // agent.js 里 `const text = serializeToolResult(result);` 就是原来那一行,位置一字未动。
20
+ //
21
+ // v0.6.17(审计 P1-1 第四刀):在同一个模块上继续切**前置门最前端的纯判据**——工具参数解析
22
+ // `parseToolArgs(raw)`。第三刀刻意否决了"整个前置门"(hooks → 白名单 → 权限 → 约束确认的顺序与
23
+ // 短路、账本内容依赖门的结果),本刀只取门**之前**、零副作用的那一步:JSON 解析 + 容错 + 归一化 +
24
+ // 错误文本。`io.renderToolDenied` / 审计 / `messages.push` 三条副作用仍留在 `agent.js`,
25
+ // 由 `ok:false` 分支按原顺序执行——顺序与短路一字未动,只是把"判据"从 1500 行的 `runTurn` 里拿了出来。
26
+ //
27
+ // `parseToolArgs()` 的**行为清单**(抽取前逐条抄下来;每个分支与抽取前逐字同源):
28
+ // 1. `raw` 为**假值**(`undefined` / `null` / `''` / `0` / `false` / `NaN`)→ 按 `'{}'` 解析
29
+ // → `{ ok:true, args:{} }`(模型没给参数 = 空参数对象;不会把假值当 JSON 去解析);
30
+ // 2. `raw` 是**合法 JSON 对象**文本(`'{"path":"a.txt","limit":3}'`)→ 原样作为 `args`
31
+ // (不增删键、不做类型转换、不 trim 字段值);
32
+ // 3. `raw` 是合法 JSON 但**不是对象**——JSON 字面量 `'null'`、数组 `'[]'`、数字 `'42'`、
33
+ // 字符串 `'"s"'`、布尔 `'true'` → **归一化为 `{}`**(数组也算非对象:工具参数必须是对象);
34
+ // 4. `raw` 是空白串(`' '`)或**非法 JSON**(`'{bad'`)→ 解析失败终态:
35
+ // `{ ok:false, deniedReason:'参数解析失败(输出超限被截断?建议分块)',
36
+ // auditReason:'参数解析失败',
37
+ // message:'工具参数 JSON 解析失败(大概率是输出超过模型单次上限被截断)。请把内容拆分成多个
38
+ // 较小文件或多次调用逐步写入:单个 write 的参数总长控制在 6000 字符以内;先写核心
39
+ // 骨架,再逐文件补充。' }`(三段文案与抽取前逐字一致);
40
+ // 5. **不做**前后缀宽容:```` ```json\n{"a":1}\n``` ````、`'{"a":1} trailing'` 都是解析失败
41
+ // (用的是严格 `JSON.parse`,不是"从文本里抠一段 JSON");
42
+ // 6. **不做**长度上限:一个 20000 字符的合法参数照样解析成功;"6000 字符以内"只是失败文案里的
43
+ // 分块建议,**不是**解析器的闸门;
44
+ // 7. 非字符串 `raw` 走 `JSON.parse` 的隐式 `ToString`:`42` → `42` → 非对象 → `{}`;
45
+ // `{}` → `'[object Object]'` → 解析失败;解析器抛出的任何异常(含 `TypeError`)都归入第 4 条;
46
+ // 8. **纯函数**:不渲染、不审计、不写消息、不执行工具,也不读全局;同一输入永远同一输出。
20
47
 
21
48
  import { clampText } from './context.js';
22
49
 
@@ -93,14 +120,20 @@ export function visibleToolsFor(schemas, { readOnlyPhase = false, usedNames = ne
93
120
  * 1. `result` 是字符串 → **原样**使用(工具已经返回了给模型看的文本,不再包一层 JSON 引号);
94
121
  * 2. 否则 → `JSON.stringify(result)` 紧凑 JSON(评估 B3:嵌套结果省 10~20% 回填 token,
95
122
  * 且这些 token 下一轮按 prompt 重复计费);
96
- * 3. 不做任何兜底/异常处理:`JSON.stringify` 抛错(循环引用)照旧上抛,
97
- * `undefined`/函数仍返回 `undefined`(见 `toolResultMessage` 的第 1 条边界)。
123
+ * 3. 顶层 `null` / `undefined`(以及函数、符号——`JSON.stringify` 对它们也返回 `undefined`)
124
+ * → **空串**(v0.6.17 第四刀附带 B,§143 收口)。理由:顶层空值都表示"没有输出";
125
+ * 不归一化时 `undefined` 会经 `clampText` 的 `String()` 变成字面量 `'undefined'`、`null`
126
+ * 变成 `'null'`,模型会把这两段噪声当成工具输出。**嵌套**的 null/undefined 不受影响
127
+ * (仍在 JSON 里按标准语义保留);
128
+ * 4. 不做其它兜底/异常处理:`JSON.stringify` 抛错(循环引用、BigInt)照旧上抛。
98
129
  *
99
130
  * @param {any} result `dispatch()` / `mcp.call()` 的返回(多数是 `{ok, output|error}` 对象或 JSON 字符串)
100
- * @returns {string} 待回填文本(运行时可能是 `undefined`,与抽取前一致)
131
+ * @returns {string} 待回填文本(顶层空值 → `''`;其余与抽取前一致)
101
132
  */
102
133
  export function serializeToolResult(result) {
103
- return typeof result === 'string' ? result : JSON.stringify(result);
134
+ if (typeof result === 'string') return result;
135
+ const text = JSON.stringify(result);
136
+ return text === undefined || text === 'null' ? '' : text;
104
137
  }
105
138
 
106
139
  /**
@@ -110,8 +143,9 @@ export function serializeToolResult(result) {
110
143
  * **行为清单**:
111
144
  * 1. 截断:`clampText(text, cap)`——`cap` 是 `toolResultCap`(按模型窗口 1/16,2000~20000 字);
112
145
  * 超长时尾部附 `…[输出过长已截断,原文共 N 字符]`。传 `undefined` 时用 `clampText` 自己的默认值。
113
- * 边界(与抽取前一致,本节只负责钉住、不负责改):`text` 为 `undefined` 时 `clampText` 内部
114
- * `String()` 得到字符串 `'undefined'`;
146
+ * 边界:本函数的**契约**是 `text` 已经过 `serializeToolResult()`(顶层空值已是空串);
147
+ * 若调用方违约直接传 `undefined`,`clampText` 的 `String()` 仍会得到字面量 `'undefined'`——
148
+ * 这是调用方违约的边界,不是回填路径(agent.js 唯一调用点走的是序列化后的文本)。
115
149
  * 2. 复用前缀:`cached` 为真(同回合相同参数的回调命中只读去重缓存)→ 正文前缀
116
150
  * `(与同回合相同调用结果一致,已复用)\n`,**且该前缀不计入 `cap`**(先截断、后加前缀);
117
151
  * 3. 领域约束(PostToolUse,v0.5.0 A3②):`post.rejected` 为真 → 正文**整段替换**为
@@ -129,3 +163,55 @@ export function toolResultMessage(text, { cached = false, cap = undefined, post
129
163
  const body = post?.rejected ? `【领域约束】${post.reason}` : text;
130
164
  return prefix + clampText(body, cap);
131
165
  }
166
+
167
+ /** `parseToolArgs()` 失败时的三段文案(与抽取前 `agent.js` 里的三处字面量逐字一致)。 */
168
+ const TOOL_ARGS_PARSE_DENIED = '参数解析失败(输出超限被截断?建议分块)';
169
+ const TOOL_ARGS_PARSE_AUDIT = '参数解析失败';
170
+ const TOOL_ARGS_PARSE_MESSAGE =
171
+ '工具参数 JSON 解析失败(大概率是输出超过模型单次上限被截断)。请把内容拆分成多个较小文件或多次调用逐步写入:单个 write 的参数总长控制在 6000 字符以内;先写核心骨架,再逐文件补充。';
172
+
173
+ /**
174
+ * 工具调用参数的**解析 / 容错 / 归一化**(v0.6.17,审计 P1-1 第四刀——前置门最前端的纯判据)。
175
+ *
176
+ * 抽取前是 `agent.js` 的 `prepTool()` 里这一段(`JSON.parse` + `catch` + 非对象归一化):
177
+ * ```js
178
+ * let args = null;
179
+ * try {
180
+ * args = JSON.parse(tc.function?.arguments || '{}');
181
+ * } catch {
182
+ * io.renderToolDenied(name, {}, '参数解析失败(输出超限被截断?建议分块)');
183
+ * if (auditOn) auditEntry({ denied: true, reason: '参数解析失败' });
184
+ * messages.push({ role: 'tool', tool_call_id: tc.id, content: '工具参数 JSON 解析失败(…)' });
185
+ * return null;
186
+ * }
187
+ * if (!args || typeof args !== 'object' || Array.isArray(args)) args = {};
188
+ * ```
189
+ * 本函数只接管**判据**;上面那三条副作用仍留在 `agent.js` 的 `ok:false` 分支里,顺序与短路不变。
190
+ *
191
+ * 行为清单(逐条与抽取前一致;§143 逐条钉死):
192
+ * 1. `raw` 为假值(`undefined` / `null` / `''` / `0` / `false` / `NaN`)→ 按 `'{}'` 解析
193
+ * → `{ ok:true, args:{} }`;
194
+ * 2. `raw` 是合法 JSON 对象文本 → 原样作为 `args`(不增删键、不做类型转换);
195
+ * 3. `raw` 是合法 JSON 但非对象(`'null'` / `'[]'` / `'42'` / `'"s"'` / `'true'`)→ 归一化为 `{}`
196
+ * (数组也算非对象:工具参数必须是对象);
197
+ * 4. 空白串 / 非法 JSON → `{ ok:false, deniedReason, auditReason, message }`(三段文案见上);
198
+ * 5. 不做前后缀宽容(严格 `JSON.parse`):```` ```json\n{"a":1}\n``` ```` 与 `'{"a":1} trailing'`
199
+ * 都是解析失败;
200
+ * 6. 不做长度上限:20000 字符的合法参数照常解析(6000 只是失败文案里的分块建议);
201
+ * 7. 非字符串 `raw` 走隐式 `ToString`:`42` → `42` → `{}`;`{}` → `'[object Object]'` → 解析失败;
202
+ * 任何解析异常都归入第 4 条;
203
+ * 8. 纯函数:不渲染、不审计、不写消息、不执行工具。
204
+ *
205
+ * @param {any} raw `tc.function?.arguments`(协议上是字符串;非字符串按 `JSON.parse` 的隐式转换处理)
206
+ * @returns {{ ok: true, args: any } | { ok: false, deniedReason: string, auditReason: string, message: string }}
207
+ */
208
+ export function parseToolArgs(raw) {
209
+ let args;
210
+ try {
211
+ args = JSON.parse(raw || '{}');
212
+ } catch {
213
+ return { ok: false, deniedReason: TOOL_ARGS_PARSE_DENIED, auditReason: TOOL_ARGS_PARSE_AUDIT, message: TOOL_ARGS_PARSE_MESSAGE };
214
+ }
215
+ if (!args || typeof args !== 'object' || Array.isArray(args)) args = {};
216
+ return { ok: true, args };
217
+ }
package/src/web/app.js CHANGED
@@ -457,6 +457,8 @@ async function send(){
457
457
  // v0.6.13(问题 3):等 `task` 子代理时把**子代理自己的进度**摆出来——
458
458
  // 真机现场界面/日志只有"等待工具 task 已 620s",与"卡死 620s"完全同形。
459
459
  if(ev.subagent) bits.push('子 Agent'+(ev.subagent.label?'「'+ev.subagent.label+'」':'')+'已跑 '+(ev.subagent.modelRounds||0)+' 轮 / '+(ev.subagent.toolCalls||0)+' 次工具调用'+(ev.subagent.ms!=null?'(最后进展 '+Math.round(ev.subagent.ms/1000)+'s 前)':''));
460
+ // v0.6.15(§3.59):本地端点串行化必须在界面上看得见(真机内存压力来自父回合 + 子代理并发)
461
+ if(ev.localQueue) bits.push('本地端点已串行化(防内存压力):等待 '+(ev.localQueue.active||0)+' 个在途请求、并发上限 '+(ev.localQueue.limit||1));
460
462
  if(ev.stalled) bits.push('已判定无进展,正在中止');
461
463
  think.textContent='💭 正在工作 '+Math.floor(p/60)+' 分 '+Math.round(p%60)+' 秒 · 第 '+msg._steps+' 回合 · '+curPhase+' · '+bits.join(' · ')+'…';
462
464
  renderWorkStatus(); }