mingdao-harness 0.6.12 → 0.6.13
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/README.md +1 -1
- package/docs/CONFIG.md +60 -5
- package/package.json +1 -1
- package/src/agent.js +275 -35
- package/src/model-discovery.js +127 -2
- package/src/web/app.js +20 -1
- package/src/web/server.js +65 -2
package/README.md
CHANGED
|
@@ -275,7 +275,7 @@ mingdao cost --by pack # 垂域费用分账
|
|
|
275
275
|
|
|
276
276
|
## 配置与扩展
|
|
277
277
|
|
|
278
|
-
配置字段、权限规则、Hooks、MCP、云同步、自定义 Provider 的完整说明见 [docs/CONFIG.md](docs/CONFIG.md);架构见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md);Provider 扩展见 [docs/PROVIDERS.md](docs/PROVIDERS.md)。
|
|
278
|
+
配置字段、权限规则、Hooks、MCP、云同步、自定义 Provider 的完整说明见 [docs/CONFIG.md](docs/CONFIG.md);架构见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md);Provider 扩展见 [docs/PROVIDERS.md](docs/PROVIDERS.md);桌面版自动更新(检查 / 下载 / 来源签名 / feed 与发版链路)见 [docs/DESKTOP-AUTO-UPDATE.md](docs/DESKTOP-AUTO-UPDATE.md)。
|
|
279
279
|
|
|
280
280
|
## 常见问题
|
|
281
281
|
|
package/docs/CONFIG.md
CHANGED
|
@@ -419,26 +419,79 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
|
|
|
419
419
|
```
|
|
420
420
|
|
|
421
421
|
- **判据**:连续 N 分钟**既没有"成功且不重复的工具结果"、也没有新的正文/推理增量** → 主动中止整个
|
|
422
|
-
回合,并如实报告「连续 N 分钟没有任何工具调用、也没有新内容,已中止」+
|
|
422
|
+
回合,并如实报告「连续 N 分钟没有任何工具调用、也没有新内容,已中止」+ 已发生的模型轮次/工具调用数,
|
|
423
|
+
外加**中止时正在等待哪个工具、等了多久**,以及与 `capped`/`aborted` 的区别
|
|
424
|
+
(capped = 跑满步数上限、可续跑;aborted = 你点的停止;stalled = 再等下去也不会产出新东西)。
|
|
423
425
|
重复调用(name+args 完全相同)与失败调用**不算进展**——真机现场正是它们把时间烧光的。
|
|
424
426
|
- **默认 60 分钟**的依据:现有单请求总量上限是本地 1800s;回合级阈值取它的 **2 倍**,
|
|
425
427
|
保证"一次慢 prefill"永远由更具体的请求级超时(首帧/空闲/总量各有文案)先说话,看门狗不抢答;
|
|
426
428
|
而真干活的回合每隔几秒就有工具结果或正文增量,60 分钟零增量在任何正常回合都不可能。
|
|
427
|
-
- 该值**不能**用 0/负数/Infinity 关掉(一律回落默认,fail-safe
|
|
428
|
-
(24
|
|
429
|
+
- 该值**不能**用 0/负数/Infinity 关掉(一律回落默认,fail-safe);子代理会**继承父回合的阈值**
|
|
430
|
+
(24 个并行子代理不会各跑各的几小时),且继承的**截止时刻是活的**——它随父回合的进展前移。
|
|
431
|
+
- **子代理的进展算父回合的进展**(v0.6.13 真机修复):父回合在 `await task` 期间拿不到任何中间信号,
|
|
432
|
+
所以子代理每有一次真实进展(成功且不重复的工具结果 / 新正文增量)就回调父回合,父回合据此续期,
|
|
433
|
+
并把快照挂在 `io.turnProgress.subagent` 上——`web-server.log` 的进度行与界面状态条因此能写出
|
|
434
|
+
「正在等待工具 task(已 620s)· 子代理「审计 agent.js」已跑 12 轮 / 17 次工具调用,最后进展 3s 前」,
|
|
435
|
+
"在等子代理"与"卡死"才分得开(真机现场日志里只有前者,看不出子代理是在干活还是死了)。
|
|
436
|
+
- **子代理卡住由它自己的看门狗先响**:父回合在等待子代理时只多等 15s
|
|
437
|
+
(`SUBAGENT_WAIT_GRACE_MS`,相对默认 60 分钟是 0.4%——**不是**放宽阈值),
|
|
438
|
+
这样子代理的收口结论("自己无进展"还是"父回合整轮无进展")能原样回传父回合,
|
|
439
|
+
而不是被父回合抢先掐断、只剩一句"父回合中止"。
|
|
440
|
+
- **stalled 在界面上必须有说明**:内核发一条告警 banner(内容与 `done.note` 同源),
|
|
441
|
+
前端在消息下方再留一条,并把结局(含两种 stalled 原因)写进控制台
|
|
442
|
+
——桌面壳日志取的就是渲染进程 console,此前那里只有「回合收尾:generating=false」,
|
|
443
|
+
与内核的 `status=stalled` 对不上。
|
|
429
444
|
|
|
430
445
|
#### 端点预检(v0.6.13,本地端点自动执行)
|
|
431
446
|
|
|
432
447
|
对**本地端点**(baseUrl 是回环/内网,或 `customModels.<名>.local: true`)在开工前做一次轻量预检
|
|
433
448
|
(结果缓存 10 分钟,只读不写你的配置):
|
|
434
449
|
|
|
435
|
-
1.
|
|
450
|
+
1. 读**引擎自述上下文**(`GET /props`,见下一条)——配置的 `contextWindow` 超没超引擎能力,只有这里能知道;
|
|
436
451
|
2. 发 **3 次**极小请求(1 条 user + 1 个工具声明 + `max_tokens: 256`),看 `tool_calls` 与 `finish_reason`,
|
|
437
452
|
给出三态之一:**稳定支持**(3/3)/ **不稳定**(N/3,会提示"agent 任务可能步数为 0")/ **不支持**(0/3);
|
|
438
453
|
被 `max_tokens` 截断的样本(`finish_reason: length`,思考型模型常见)判为**不可判定**,不当作"不支持";
|
|
454
|
+
(引擎实际加载的模型名从 chat 响应的 `model` 字段读——**不**额外打 `GET /models`:有的端点没实现它,
|
|
455
|
+
而 llama.cpp 在流式响应里直接回完整文件路径,语义等价且少一个请求。)
|
|
439
456
|
3. 再发一次 **参考 prefill**(约 2000 tokens 的提示)测耗时;
|
|
440
457
|
4. 与本机历史值比对:**同一端点明显变慢**(≥3 倍)会提示"该端点可能仍在处理上一个请求,建议重启引擎"。
|
|
441
458
|
|
|
459
|
+
#### 预检读「引擎自述上下文」(v0.6.13)
|
|
460
|
+
|
|
461
|
+
本地端点预检时**额外读一次引擎自述**(`GET /props`,只读、不改配置):
|
|
462
|
+
|
|
463
|
+
- llama.cpp 的 `/props` 通常返回 `default_generation_settings.n_ctx`(有的版本另有顶层 `n_ctx`/`n_ctx_train`);
|
|
464
|
+
其他引擎能读多少读多少(`max_model_len`、`context_length`、`model_info.*.context_length`),
|
|
465
|
+
**读不到就跳过并说明**(不猜、不编造、也不据此判定端点不行)。
|
|
466
|
+
`/props` 挂在根上,而 `baseUrl` 通常带 `/v1`,预检会先剥掉 `/v\d+` 再试,失败再退回原样。
|
|
467
|
+
- **什么时候会去打这个请求**:只有端点**自报了模型名**才打。llama.cpp 家族(含 llama-cpp-python/
|
|
468
|
+
koboldcpp/LM Studio)总会在响应里回模型名或模型文件路径;回不了名的多半是"只实现了
|
|
469
|
+
`/v1/chat/completions`"的网关或测试桩——它们不认识 `/props`,多打一个 GET 可能把对方打断
|
|
470
|
+
(本仓 `test/e2e-web.js` 的 mock 就是这样被打挂的:它只按 `POST + JSON body` 解析)。
|
|
471
|
+
跳过时结论里会写「引擎自述上下文:跳过(该端点未自报模型名,不确认它实现 /props)」,
|
|
472
|
+
不会假装核对过。
|
|
473
|
+
- 结论会与配置**并排**写出来,同时出现在界面 banner、`web-server.log`
|
|
474
|
+
(`预检 <模型> … 引擎自述上下文=… 配置=…`)与发送前的规模预告里:
|
|
475
|
+
|
|
476
|
+
```
|
|
477
|
+
⚠ 引擎自述上下文 32768 vs 配置 131072 —— 配置超出引擎能力,长上下文将直接被引擎拒绝
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
- 为什么这条必须有:真机实测配置 `contextWindow: 131072`、引擎只装了 32768,
|
|
481
|
+
15,983 tokens 的 prompt 在 prefill 阶段就被引擎拒绝(`insufficient memory: the request exceeded
|
|
482
|
+
available GPU memory … during prefill`)。此时**换预设、调小 `contextBudget`、调大 `timeout.*`
|
|
483
|
+
都无效**——它们改不了引擎的 `n_ctx`;要么在引擎侧把上下文窗口调大
|
|
484
|
+
(llama.cpp 的 `--ctx-size` / `--context-window`),要么把 `customModels.<名>.contextWindow` 改成 ≤ 引擎值。
|
|
485
|
+
|
|
486
|
+
#### 发送前的规模预告由预检实测推导(v0.6.13)
|
|
487
|
+
|
|
488
|
+
回合开始前的 `📏 本轮上下文 ≈ …` 预告,在**有预检实测**时按
|
|
489
|
+
`参考 prefill 耗时 × 本轮 tokens ÷ 参考 tokens` 线性外推,并在文案里注明这是外推、以及
|
|
490
|
+
"长上下文下引擎可能显著变慢"的实测差值(本机 16k tokens 首帧 53.7s,而按同一份预检外推只有 0.6s)——
|
|
491
|
+
只报乐观值会训练用户忽略这条告警。**只有拿不到预检数据**(端点不可达 / 不可判定)时才退回
|
|
492
|
+
"prefill 可能需数分钟至数十分钟"的保守文案;外推超过 60s(`PREFILL_SLOW_WARN_MS`)才告警,
|
|
493
|
+
且必须写出依据(预检实测值 + 本轮 tokens + 阈值)。
|
|
494
|
+
|
|
442
495
|
预检结论会出现在:回合开始时的界面 banner、`web-server.log`(`预检 <模型> …` 行)、
|
|
443
496
|
以及发送前的规模预告里。并且**按实测 TTFT 自适应**超时:首帧上限 = clamp(max(实测首帧×20, 参考prefill×5),
|
|
444
497
|
既有默认, 1800s)、无进展看门狗 = clamp(max(实测首帧×100, 参考prefill×20), 10min, 60min);
|
|
@@ -497,7 +550,9 @@ curl -s http://127.0.0.1:8081/v1/models | head -c 400 # 端口按你的部
|
|
|
497
550
|
| 引擎没起来 / 端口不对 | 预检 `❌ 不可达:…`;请求以 `网络请求失败` 报错(几秒内) |
|
|
498
551
|
| 连上但不发首帧 | 到 `timeout.firstTokenMs` 报「首 token 等待超限(Ns,本地模型长上下文 prefill 可能很慢)——可调大 config.timeout.firstTokenMs…」 |
|
|
499
552
|
| 有帧但长时间没新数据 | 到 `timeout.streamIdleMs`(默认 120s)报「流式响应空闲超限(Ns 无新数据)」 |
|
|
500
|
-
| 每轮都能返回、但什么都没做成 | 到 `noProgressTimeoutMs`(默认 60min,快端点自适应到 10min)报「连续 N
|
|
553
|
+
| 每轮都能返回、但什么都没做成 | 到 `noProgressTimeoutMs`(默认 60min,快端点自适应到 10min)报「连续 N 分钟没有任何工具调用、也没有新内容,已中止」,并附**中止时正在等待哪个工具/等了多久**;界面另有告警 banner(与 `capped` 跑满步数、`aborted` 你点的停止**区分开**) |
|
|
554
|
+
| 一直在等 `task` 子代理 | 进度行/状态条会写出**子代理自己的进度**(第几轮、几次工具调用、最后进展距今多久);子代理有进展时父回合不会被判无进展,子代理真卡住则由**它自己的**看门狗先中止并把原因回传父回合 |
|
|
555
|
+
| 引擎装不下配置的窗口 | 预检读 `GET /props` 得到引擎自述上下文,与配置并排;配置超出即告警「配置超出引擎能力,长上下文将直接被引擎拒绝」(换预设/调 `contextBudget` 都无效) |
|
|
501
556
|
| 上游 4xx/5xx(模板/内存/鉴权…) | 错误里带**上游原文**并附一条可操作判断(例如"该端点的模板不接受当前 messages 形态") |
|
|
502
557
|
|
|
503
558
|
**第 5 步:模型不支持工具调用时会怎样**
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -67,6 +67,15 @@ const SUBAGENT_MAX_STEPS = 24;
|
|
|
67
67
|
* 可配置:config.noProgressTimeoutMs(0/负数/NaN/Infinity 一律回落默认——**不能**用它关掉看门狗)。 */
|
|
68
68
|
export const DEFAULT_NO_PROGRESS_TIMEOUT_MS = 3600000;
|
|
69
69
|
|
|
70
|
+
/** 父回合在等待 `task` 子代理时额外给的一点点宽限(ms)。
|
|
71
|
+
*
|
|
72
|
+
* v0.6.13(问题 3):子代理的看门狗与父回合是**同一个阈值**,两者会在同一刻到点;
|
|
73
|
+
* 必须让**子代理先响**——否则父回合先掐断,`task` 的工具结果里只剩"父回合中止",
|
|
74
|
+
* 用户看不出子代理自己卡在哪一步、跑到了第几轮(真机现场正是这种形态)。
|
|
75
|
+
* 这不是"把阈值调大掩盖问题":判据没动、默认值没动,只是在"父回合正等子代理"这一种
|
|
76
|
+
* 形态下多等 15s(相对默认 60 分钟是 0.4%),换"子代理自己的结论能回传父回合"。 */
|
|
77
|
+
export const SUBAGENT_WAIT_GRACE_MS = 15000;
|
|
78
|
+
|
|
70
79
|
/** 解析无进展阈值:非正/非有限一律回落默认(fail-safe,防"配置写 0 就静默失去看门狗")。 */
|
|
71
80
|
export function resolveNoProgressTimeoutMs(/** @type {any} */ cfg) {
|
|
72
81
|
const v = Number(cfg?.noProgressTimeoutMs);
|
|
@@ -75,17 +84,69 @@ export function resolveNoProgressTimeoutMs(/** @type {any} */ cfg) {
|
|
|
75
84
|
|
|
76
85
|
/** 上下文占窗口比例的告警线(≥ 此值 → 显式警告"缩小任务/或调大 firstTokenMs")。 */
|
|
77
86
|
export const PREFILL_WARN_RATIO = 0.8;
|
|
78
|
-
/** 本地端点的 prompt 规模告警线(tokens
|
|
87
|
+
/** 本地端点的 prompt 规模告警线(tokens):**只在没有预检数据时**用它兜底(有实测就不用规模猜)。 */
|
|
79
88
|
export const PREFILL_WARN_TOKENS = 60000;
|
|
89
|
+
/** 由预检外推的 prefill 超过它就显式告警(ms)。依据见下面 prefillForecast 的注释。 */
|
|
90
|
+
export const PREFILL_SLOW_WARN_MS = 60000;
|
|
91
|
+
/** "线性外推会低估"的**实测证据**(写进文案,让用户知道外推值不是承诺)。
|
|
92
|
+
*
|
|
93
|
+
* 真机(负责人 2026-10-09,同一个 taskId t424aaab2aecab8a3):
|
|
94
|
+
* 04:13:27 预告 本轮上下文 ≈ 15,983 tokens;预检「参考 prefill 2000 tokens 0.08s」
|
|
95
|
+
* 04:14:17 chat 首帧 t424aaab2aecab8a3 **53.7s**
|
|
96
|
+
* 线性外推 = 0.08s × 15983/2000 ≈ 0.6s,实测 53.7s —— **差约 90 倍**。
|
|
97
|
+
* 原因:2000 tokens 的参考样本落在引擎的短上下文快路径上;真实任务把 KV cache 占满后,
|
|
98
|
+
* dequant/内存压力会让 prefill 显著变慢(真机现场还有 `insufficient memory … during prefill`)。
|
|
99
|
+
* 所以文案**只报外推值是不够的**,必须同时说出这条实测差值。 */
|
|
100
|
+
export const PREFILL_LONGCTX_EVIDENCE = '本机实测 16k tokens 首帧 53.7s,而按同一份预检外推只有 0.6s(约 90 倍)';
|
|
101
|
+
|
|
102
|
+
/** 把毫秒格式化成给人看的时长(<60s 用秒、否则 m分s秒)。 */
|
|
103
|
+
const fmtDur = (/** @type {number} */ ms) => {
|
|
104
|
+
const s = Number(ms) / 1000;
|
|
105
|
+
if (!Number.isFinite(s) || s < 0) return '?';
|
|
106
|
+
return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, '0')}s`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** 参考 prefill 的耗时格式化:保留两位(76ms → 0.08s),否则"0.1s/2000 tokens 外推"会把
|
|
110
|
+
* 预检实测值四舍五入到看不出量级(真机预检正是 0.08s 这一档)。 */
|
|
111
|
+
const fmtRefDur = (/** @type {number} */ ms) => {
|
|
112
|
+
const s = Number(ms) / 1000;
|
|
113
|
+
if (!Number.isFinite(s) || s < 0) return '?';
|
|
114
|
+
return s < 60 ? `${s.toFixed(2)}s` : fmtDur(ms);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 由**预检实测**外推本轮 prefill 耗时(纯函数,便于断言/变异)。
|
|
119
|
+
*
|
|
120
|
+
* 为什么是线性外推而不是照搬老文案:老文案对任何本地端点都说"prefill 可能需数分钟至数十分钟",
|
|
121
|
+
* 而同一条预告的后半句又写着"参考 prefill 2000 tokens 0.08s"——**同一行自相矛盾**,
|
|
122
|
+
* 结果就是用户学会忽略这条告警(本次真机 04:13:27 的现场)。
|
|
123
|
+
*
|
|
124
|
+
* 判据(只认实测,不认规模):
|
|
125
|
+
* · 有 `probe.prefillMs`(预检测过固定参考 prefill)→ `prefillMs × 本轮tokens / 参考tokens`;
|
|
126
|
+
* · 没有 → 返回 null,调用方**退回**保守文案(不可达 / 不可判定 / 未预检)。
|
|
127
|
+
* 外推的已知偏差(长上下文下引擎显著变慢)不由本函数掩盖——由调用方写进文案(见 PREFILL_LONGCTX_EVIDENCE)。
|
|
128
|
+
* @param {{ promptTokens: number, probe?: any }} input
|
|
129
|
+
* @returns {{ ms: number, refTokens: number, refMs: number, tokens: number }|null}
|
|
130
|
+
*/
|
|
131
|
+
export function prefillEstimateMs({ promptTokens, probe = null }) {
|
|
132
|
+
const p = Math.max(0, Math.round(Number(promptTokens) || 0));
|
|
133
|
+
const refMs = Number(probe?.prefillMs);
|
|
134
|
+
if (!(refMs > 0) || p <= 0) return null;
|
|
135
|
+
const refTokens = Number(probe?.prefillTokens) > 0 ? Number(probe.prefillTokens) : 2000;
|
|
136
|
+
return { ms: Math.round((refMs * p) / refTokens), refTokens, refMs, tokens: p };
|
|
137
|
+
}
|
|
80
138
|
|
|
81
139
|
const fmtTok = (/** @type {any} */ n) => Number(n || 0).toLocaleString('en-US');
|
|
82
140
|
|
|
83
141
|
/**
|
|
84
142
|
* 发送前的规模预告(纯函数,便于断言/变异):把"这次要 prefill 多少"在**请求发出前**说出来。
|
|
85
143
|
* 本地端点尤其重要——它既不提供 prefill 进度,首帧又可能是几十分钟。
|
|
144
|
+
*
|
|
145
|
+
* v0.6.13(问题 1 修法):**有预检实测就必须由它推导**,并在文案里如实标注
|
|
146
|
+
* ①这是外推、②长上下文下外推会低估(附实测差值);只有在**没有预检数据**时才退回保守文案。
|
|
86
147
|
* @param {{ promptTokens: number, contextWindow?: number, isLocal?: boolean,
|
|
87
148
|
* firstTokenMs?: number, budget?: number, probe?: any }} input
|
|
88
|
-
* @returns {{ text: string, warn: boolean, promptTokens: number, ratio: number }}
|
|
149
|
+
* @returns {{ text: string, warn: boolean, promptTokens: number, ratio: number, estimateMs: number|null }}
|
|
89
150
|
*/
|
|
90
151
|
export function prefillForecast({ promptTokens, contextWindow, isLocal, firstTokenMs, budget, probe = null }) {
|
|
91
152
|
const p = Math.max(0, Math.round(Number(promptTokens) || 0));
|
|
@@ -94,38 +155,97 @@ export function prefillForecast({ promptTokens, contextWindow, isLocal, firstTok
|
|
|
94
155
|
const pct = Math.round(ratio * 100);
|
|
95
156
|
const secs = Number(firstTokenMs) > 0 ? Math.round(Number(firstTokenMs) / 1000) : 0;
|
|
96
157
|
const head = `📏 本轮上下文 ≈ ${fmtTok(p)} tokens${win ? `(${fmtTok(win)} 窗口的 ${pct}%)` : ''}${Number(budget) > 0 ? `,预算 ${fmtTok(budget)}` : ''}`;
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
//
|
|
158
|
+
const est = prefillEstimateMs({ promptTokens: p, probe });
|
|
159
|
+
const estNote = est ? `按预检 ${fmtRefDur(est.refMs)}/${fmtTok(est.refTokens)} tokens 外推,本轮 ≈${fmtDur(est.ms)}` : '';
|
|
160
|
+
// 三个分支(顺序即优先级):
|
|
161
|
+
// ① 有预检实测 → 报外推值 + **如实**说明长上下文会低估(附真机差值);
|
|
162
|
+
// ② 无预检数据(不可达/不可判定/未预检)→ 退回既有保守文案(真机 01:26 那次就是这一支);
|
|
163
|
+
// ③ 远程端点 → 各自一句。
|
|
164
|
+
const timing = est
|
|
165
|
+
? `${isLocal ? '本地端点' : '远程端点'}:${estNote}` +
|
|
166
|
+
`(**线性外推**,只作量级参考——长上下文下引擎可能显著变慢:${PREFILL_LONGCTX_EVIDENCE};` +
|
|
167
|
+
`外推低估时以首帧等待为准);首帧等待上限 ${secs}s(config.timeout.firstTokenMs)`
|
|
168
|
+
: isLocal
|
|
169
|
+
? `本地端点:prefill 可能需数分钟至数十分钟(该端点不提供进度预告,只能等首帧);首帧等待上限 ${secs}s(config.timeout.firstTokenMs)`
|
|
170
|
+
: `远程端点:首帧通常在数十秒内;首帧等待上限 ${secs}s(config.timeout.firstTokenMs)`;
|
|
171
|
+
// 三条告警线:
|
|
172
|
+
// · 占窗口 ≥80%:任何端点都危险(与 prefill 快慢无关,这是"上下文压力");
|
|
173
|
+
// · **外推** prefill ≥ PREFILL_SLOW_WARN_MS:端点实测明显慢,依据 = 预检实测 × 本轮 tokens;
|
|
174
|
+
// · 本地端点 + prompt ≥ PREFILL_WARN_TOKENS:**仅在拿不到预检数据时**兜底
|
|
175
|
+
// (有实测就不再用规模猜——那正是"0.08s 的端点被说成需要数十分钟"的来源)。
|
|
101
176
|
const hitRatio = ratio >= PREFILL_WARN_RATIO;
|
|
102
|
-
const
|
|
103
|
-
const
|
|
177
|
+
const hitSlow = est != null && est.ms >= PREFILL_SLOW_WARN_MS;
|
|
178
|
+
const hitLocal = est == null && Boolean(isLocal) && p >= PREFILL_WARN_TOKENS;
|
|
179
|
+
const warn = hitRatio || hitSlow || hitLocal;
|
|
180
|
+
const why = hitRatio
|
|
181
|
+
? `上下文已占窗口 ${pct}%(≥${Math.round(PREFILL_WARN_RATIO * 100)}%)`
|
|
182
|
+
: hitSlow
|
|
183
|
+
? `按预检外推本轮 prefill ≈${fmtDur(est ? est.ms : 0)}(${est ? fmtRefDur(est.refMs) : '?'}/${est ? fmtTok(est.refTokens) : '?'} tokens × ${fmtTok(p)} tokens,≥${Math.round(PREFILL_SLOW_WARN_MS / 1000)}s 告警线)`
|
|
184
|
+
: `本地端点 + 上下文 ${fmtTok(p)} tokens(≥${fmtTok(PREFILL_WARN_TOKENS)}),且本次没有任何预检实测可外推`;
|
|
104
185
|
const advice = warn
|
|
105
|
-
? `\n ⚠ ${
|
|
186
|
+
? `\n ⚠ ${why}:` +
|
|
106
187
|
`建议缩小任务(新建会话、只给必要文件、先 /compact)或调大 config.timeout.firstTokenMs——` +
|
|
107
188
|
`但注意调大它只会让"失败"更晚被发现,不会让 prefill 变快。`
|
|
108
189
|
: '';
|
|
109
190
|
// v0.6.13(A 追加):把**端点预检**结果并进这条预告——"这个端点行不行"与"这次要 prefill 多少"
|
|
110
191
|
// 必须一起出现在发送前,而不是等出事后翻日志(引擎实际加载的模型名与配置名不一致也在这里说)。
|
|
111
192
|
const probeLine = probe && probe.note ? `\n 端点预检:${probe.note}` : '';
|
|
112
|
-
return { text: `${head}\n ${timing}${probeLine}${advice}`, warn, promptTokens: p, ratio };
|
|
193
|
+
return { text: `${head}\n ${timing}${probeLine}${advice}`, warn, promptTokens: p, ratio, estimateMs: est ? est.ms : null };
|
|
113
194
|
}
|
|
114
195
|
|
|
115
196
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
197
|
+
* 无进展中止文案(纯函数):必须同时说清「多久没进展」「发生了什么」「在等谁」「下一步怎么办」,
|
|
198
|
+
* 并且**与 capped/aborted 区分开**(v0.6.13 问题 4:真机上用户只看到一句"回合收尾",
|
|
199
|
+
* 分不清"跑满了步数(可续跑)""我点了停止"和"再等下去也不会有新东西")。
|
|
200
|
+
* @param {{ waitedMs: number, toolCalls?: number, modelRounds?: number, unproductive?: number,
|
|
201
|
+
* pendingTool?: { name: string, ms: number }|null,
|
|
202
|
+
* subagent?: { label?: string, modelRounds?: number, toolCalls?: number, at?: number }|null }} input
|
|
118
203
|
*/
|
|
119
|
-
export function noProgressNotice({ waitedMs, toolCalls = 0, modelRounds = 0, unproductive = 0 }) {
|
|
204
|
+
export function noProgressNotice({ waitedMs, toolCalls = 0, modelRounds = 0, unproductive = 0, pendingTool = null, subagent = null }) {
|
|
205
|
+
// v0.6.13(问题 3 附带修):不足 1 分钟必须按**秒**说。原实现 Math.max(1, round(ms/60000)) 会把
|
|
206
|
+
// 600ms 说成"连续 1 分钟"——真机上子代理被掐时就谎报过"连续 1 分钟"(它其实一直在干活)。
|
|
120
207
|
const min = Math.max(1, Math.round(Number(waitedMs) / 60000));
|
|
208
|
+
const howLong = Number(waitedMs) >= 60000 ? `${min} 分钟` : `${Math.max(1, Math.round(Number(waitedMs) / 1000))} 秒`;
|
|
121
209
|
const extra = Number(unproductive) > 0 ? `——其中 ${Number(unproductive)} 次工具调用是**重复调用或失败**(没有产生任何新信息)` : '';
|
|
210
|
+
const waiting = pendingTool
|
|
211
|
+
? `\n ⏳ 中止时**正在等待工具 ${pendingTool.name}**(已 ${Math.round(Number(pendingTool.ms) / 1000)}s)` +
|
|
212
|
+
(subagent
|
|
213
|
+
? `;该子代理最后进展:第 ${Number(subagent.modelRounds) || 0} 轮 / ${Number(subagent.toolCalls) || 0} 次工具调用` +
|
|
214
|
+
(Number(subagent.at) > 0 ? `(${Math.max(0, Math.round((Date.now() - Number(subagent.at)) / 1000))}s 前)` : '') +
|
|
215
|
+
'。子代理自己的看门狗会先于父回合中止它并回传原因;这里先响说明**连子代理也没有新进展**。'
|
|
216
|
+
: '。')
|
|
217
|
+
: '';
|
|
122
218
|
return (
|
|
123
|
-
`⏹ 连续 ${
|
|
124
|
-
`
|
|
219
|
+
`⏹ 连续 ${howLong}没有任何工具调用、也没有新内容(模型请求 ${modelRounds} 轮、工具调用 ${toolCalls} 次${extra}),已中止。\n` +
|
|
220
|
+
` ⓘ 这是**整轮零进展**(不是步数上限 capped、也不是你点的停止 aborted):capped 的回合可以接着续跑,本条是"再等下去也不会产出新东西"。` +
|
|
221
|
+
waiting +
|
|
222
|
+
`\n 常见原因:① 该端点/模型不做 function calling(tool_calls 恒为空);② 本地引擎的 prefill 已停滞或内存吃紧;③ 上游连接还在但不再产出。\n` +
|
|
125
223
|
` 下一步:把任务拆小(新建会话、只给必要文件)或换支持工具调用的端点;阈值可调 config.noProgressTimeoutMs(默认 ${Math.round(DEFAULT_NO_PROGRESS_TIMEOUT_MS / 60000)} 分钟)。`
|
|
126
224
|
);
|
|
127
225
|
}
|
|
128
226
|
|
|
227
|
+
/**
|
|
228
|
+
* 子代理因**父回合的截止时刻**收口时的文案(纯函数)。
|
|
229
|
+
*
|
|
230
|
+
* 为什么必须与 noProgressNotice 分开:真机现场(~/.mingdao/sessions/2026-10-09T02-42-31-qaaa.jsonl
|
|
231
|
+
* 第 28/29 行)两条 `task` 结果都写「连续 1 分钟没有任何工具调用」,而同一句话里又写着
|
|
232
|
+
* 「模型请求 15 轮、工具调用 18 次」——子代理**一直在干活**。原因是继承的截止时刻是
|
|
233
|
+
* spawn 那一刻的**绝对快照**,到点即掐,触发时又拿"子代理自己的最后进展"当等待时长,于是谎报。
|
|
234
|
+
* 修法:①截止时刻随父回合进展前移(不再掐正在干活的子代理);②真到点时必须如实说
|
|
235
|
+
* "是父回合判定整轮无进展、不是子代理卡住"。
|
|
236
|
+
* @param {{ waitedMs?: number, toolCalls?: number, modelRounds?: number }} input
|
|
237
|
+
*/
|
|
238
|
+
export function inheritedStopNotice({ waitedMs = 0, toolCalls = 0, modelRounds = 0 }) {
|
|
239
|
+
const secs = Math.max(0, Math.round(Number(waitedMs) / 1000));
|
|
240
|
+
const ago = secs >= 60 ? `${Math.round(secs / 60)} 分钟` : `${secs} 秒`;
|
|
241
|
+
return (
|
|
242
|
+
`⏹ 父回合已判定**整轮无进展**,子代理随整棵子代理树一起收口,已中止。\n` +
|
|
243
|
+
` 子代理自身最后一次真实进展在 ${ago}前(模型请求 ${modelRounds} 轮、工具调用 ${toolCalls} 次)——` +
|
|
244
|
+
`即**不是子代理卡住**,而是父回合整体零进展(父回合没有把子代理的进展计为进展时就会走到这里)。\n` +
|
|
245
|
+
` 下一步:把子任务拆小、减少并行子代理数,或调大 config.noProgressTimeoutMs(默认 ${Math.round(DEFAULT_NO_PROGRESS_TIMEOUT_MS / 60000)} 分钟)。`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
129
249
|
/**
|
|
130
250
|
* 回合结束时的「0 次工具调用」提示(纯函数)。
|
|
131
251
|
* 触发条件:**任务态**(用户消息含写/执行意图)或发了多轮请求,却一次工具调用都没有——
|
|
@@ -181,9 +301,10 @@ export function describeUpstreamError(/** @type {any} */ err) {
|
|
|
181
301
|
* 进度行(纯函数):web-server.log 与界面状态条**共用同一份文案**,口径不会漂移。
|
|
182
302
|
* @param {{ phase?: string, elapsedMs: number, modelRounds?: number, toolCalls?: number,
|
|
183
303
|
* toolExecuted?: number, contentChars?: number, ttftMs?: number|null,
|
|
184
|
-
* pendingTool?: { name: string, ms: number }|null, tokensPerSec?: number|null
|
|
304
|
+
* pendingTool?: { name: string, ms: number }|null, tokensPerSec?: number|null,
|
|
305
|
+
* subagent?: { label?: string, modelRounds?: number, toolCalls?: number, at?: number }|null }} p
|
|
185
306
|
*/
|
|
186
|
-
export function formatTurnProgress({ phase, elapsedMs, modelRounds = 0, toolCalls = 0, toolExecuted = 0, contentChars = 0, ttftMs = null, pendingTool = null, tokensPerSec = null }) {
|
|
307
|
+
export function formatTurnProgress({ phase, elapsedMs, modelRounds = 0, toolCalls = 0, toolExecuted = 0, contentChars = 0, ttftMs = null, pendingTool = null, tokensPerSec = null, subagent = null }) {
|
|
187
308
|
const s = Math.max(0, Math.round(Number(elapsedMs) / 1000));
|
|
188
309
|
const parts = [
|
|
189
310
|
`已跑 ${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`,
|
|
@@ -195,6 +316,16 @@ export function formatTurnProgress({ phase, elapsedMs, modelRounds = 0, toolCall
|
|
|
195
316
|
if (tokensPerSec != null) parts.push(`≈${Number(tokensPerSec).toFixed(1)} tok/s`);
|
|
196
317
|
parts.push(`已收正文=${contentChars} 字`);
|
|
197
318
|
if (pendingTool) parts.push(`正在等待工具 ${pendingTool.name}(已 ${Math.round(Number(pendingTool.ms) / 1000)}s)`);
|
|
319
|
+
// v0.6.13(问题 3):等 `task` 子代理时必须能看出"这个子任务跑到哪一步"——
|
|
320
|
+
// 否则"等子代理 620s"与"卡死 620s"在日志上完全同形(真机现场正是如此)。
|
|
321
|
+
if (subagent) {
|
|
322
|
+
const label = String(subagent.label || '').trim();
|
|
323
|
+
const ago = Number(subagent.at) > 0 ? Math.max(0, Math.round((Date.now() - Number(subagent.at)) / 1000)) : null;
|
|
324
|
+
parts.push(
|
|
325
|
+
`子代理${label ? `「${label}」` : ''}已跑 ${Number(subagent.modelRounds) || 0} 轮 / ${Number(subagent.toolCalls) || 0} 次工具调用` +
|
|
326
|
+
(ago != null ? `,最后进展 ${ago}s 前` : '')
|
|
327
|
+
);
|
|
328
|
+
}
|
|
198
329
|
return parts.join(' · ');
|
|
199
330
|
}
|
|
200
331
|
|
|
@@ -372,20 +503,36 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
372
503
|
: { check: (/** @type {any} */ name, /** @type {any} */ args) => permission.check(name, args, '(子任务)') };
|
|
373
504
|
// 自动路由:子代理固定走 executor 模型(便宜的执行单元)
|
|
374
505
|
const subModel = subagentModel(cfg, modelName);
|
|
506
|
+
// v0.6.13(A):子代理**继承父回合的无进展截止时刻**——24 个并行子代理各自跑几十分钟时,
|
|
507
|
+
// 父回合不该等它们把各自的看门狗超时跑完(真机实测:一个回合因此跑了 4+ 小时)。
|
|
508
|
+
// v0.6.13(问题 3 修法):这个 `turnProgressDeadlineAt` **只是 spawn 时刻的快照兜底**;
|
|
509
|
+
// 真正的判据在子代理侧取 `parentProgress`(父回合的进度对象,**活的引用**):
|
|
510
|
+
// · 快照版的问题(真机 02:42–03:13):子代理刚有进展(15 轮 / 18 次工具调用)也被
|
|
511
|
+
// "父回合剩余时间"掐掉,而且触发时按子代理自己的最后进展算等待时长 → 谎报"连续 1 分钟";
|
|
512
|
+
// · 现在子代理每次真实进展都会回调父回合(onSubagentProgress)→ 父回合续期 →
|
|
513
|
+
// 父回合的 lastProgressAt 前移 → 子代理读到的截止时刻随之前移。**真在干活就不收口**。
|
|
375
514
|
const subAgent = createAgent({
|
|
376
515
|
provider,
|
|
377
516
|
permission: subPermission,
|
|
378
517
|
io: subIo,
|
|
379
518
|
modelName: subModel,
|
|
380
519
|
workingDir,
|
|
381
|
-
// v0.6.13(A):子代理**继承父回合的无进展截止时刻**——24 个并行子代理各自跑几十分钟时,
|
|
382
|
-
// 父回合不该等它们把各自的看门狗超时跑完(真机实测:一个回合因此跑了 4+ 小时)。
|
|
383
520
|
cfg: {
|
|
384
521
|
...cfg,
|
|
385
522
|
contextBudget: Math.min(budget, 64000),
|
|
386
523
|
...(currentTurnProgress
|
|
387
524
|
? { turnProgressDeadlineAt: currentTurnProgress.lastProgressAt + currentTurnProgress.noProgressMs }
|
|
388
525
|
: {}),
|
|
526
|
+
// 下面是 v0.6.13(问题 3)补的两项。**上面三行一个字都不动**:那个 spread 是 §139 与
|
|
527
|
+
// batch28 ⑥ 共同钉住的形状(改缩进/换写法会让既有变异变成"变异点未找到"——本轮实际踩到过),
|
|
528
|
+
// 而它现在的语义只当"spawn 时刻的快照兜底",真正的判据是 parentProgress(活引用)。
|
|
529
|
+
...(currentTurnProgress ? { parentProgress: currentTurnProgress } : {}),
|
|
530
|
+
...(description ? { subagentLabel: description } : {}),
|
|
531
|
+
onSubagentProgress: (/** @type {any} */ info) => {
|
|
532
|
+
try {
|
|
533
|
+
currentTurnProgress?.onSubagentProgress?.(info);
|
|
534
|
+
} catch {}
|
|
535
|
+
},
|
|
389
536
|
},
|
|
390
537
|
undoStore: undo,
|
|
391
538
|
maxSteps: SUBAGENT_MAX_STEPS,
|
|
@@ -423,11 +570,22 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
423
570
|
}
|
|
424
571
|
// v0.4.1:子代理空输出给主线程可用的失败信号(含 note 原因),而非笼统「无输出」——
|
|
425
572
|
// 主智能体据此决定是否重试/换法,而非把子代理静默当作「已完成但没说话」。
|
|
573
|
+
// v0.6.13(问题 3):把子代理**自己跑到哪一步**一并回传(轮次/工具调用/最后进展距今)——
|
|
574
|
+
// 真机上父回合只知道"在等 task 620s",完全看不出子代理是在干活还是卡死;
|
|
575
|
+
// 而子代理被收口的**原因**(自己无进展 / 父回合整轮无进展)也必须原样带给父回合。
|
|
576
|
+
const subTp = /** @type {any} */ (/** @type {any} */ (subIo).turnProgress);
|
|
577
|
+
const subStat = subTp
|
|
578
|
+
? `已跑 ${Number(subTp.modelRounds) || 0} 轮 / ${Number(subTp.toolCalls) || 0} 次工具调用` +
|
|
579
|
+
(Number(subTp.lastProgressAt) > 0 ? `,最后进展 ${Math.max(0, Math.round((Date.now() - Number(subTp.lastProgressAt)) / 1000))}s 前` : '') +
|
|
580
|
+
(subTp.stallCause === 'inherited' ? '(因父回合整轮无进展而收口)' : subTp.stalled ? '(子代理自身无进展而收口)' : '')
|
|
581
|
+
: '';
|
|
426
582
|
const text =
|
|
427
583
|
res.text ||
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
584
|
+
`(子任务${res.stalled ? '被无进展看门狗中止' : res.truncated ? '达到步骤上限,未完成' : '无输出'}` +
|
|
585
|
+
(subStat ? `;${subStat}` : '') +
|
|
586
|
+
(res.note ? ':' + res.note : '') +
|
|
587
|
+
')';
|
|
588
|
+
io.print(style(` ↳ 子任务完成(${ms}ms${res.stalled ? ',被无进展看门狗中止' : ''})`, C.magenta));
|
|
431
589
|
return text;
|
|
432
590
|
}
|
|
433
591
|
|
|
@@ -631,6 +789,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
631
789
|
llmCalls: turnProgress.llmCalls,
|
|
632
790
|
ttftMs: turnProgress.ttftMs,
|
|
633
791
|
stalled: turnProgress.stalled,
|
|
792
|
+
// v0.6.13(问题 3/4):stalled 的**原因**('own' 自己零进展 / 'inherited' 父回合整轮零进展)——
|
|
793
|
+
// 前端与日志据此区分"子代理卡住"和"父回合没算子代理的进展"(真机上这两者被混成一句)。
|
|
794
|
+
stallCause: turnProgress.stallCause,
|
|
795
|
+
subagent: turnProgress.subagent,
|
|
634
796
|
// v0.6.13(A):收尾提示的**单一来源**(服务端直接取这条字符串,不再自己拼一遍文案)。
|
|
635
797
|
// 注意:return 里的 perf() 先于 finally 求值,所以这里必须**惰性计算**(否则返回值里恒为 null,
|
|
636
798
|
// 而日志/界面那边却已经打印过——同一事实两个说法,正是本项目反复栽过的那类不一致)。
|
|
@@ -678,8 +840,6 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
678
840
|
// 快照挂在 io 上:WebUI 服务端(src/web/server.js)每 5s 读它下发状态条、每 30s 落一行日志。
|
|
679
841
|
// 这里不改 web-io.js 的接口(它不在本次改动面内),字段是"只读约定"。
|
|
680
842
|
const noProgressMs = resolveNoProgressTimeoutMs(cfg);
|
|
681
|
-
// 子代理共享父回合的"最迟进展时刻":24 个并行子代理各跑几十分钟时,父回合不该等它们各自的超时。
|
|
682
|
-
const inheritedDeadline = Number(cfg.turnProgressDeadlineAt) > 0 ? Number(cfg.turnProgressDeadlineAt) : null;
|
|
683
843
|
/** @type {any} */
|
|
684
844
|
const turnProgress = {
|
|
685
845
|
startedAt,
|
|
@@ -695,11 +855,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
695
855
|
ttftMs: null,
|
|
696
856
|
lastTtftMs: null,
|
|
697
857
|
pendingTool: null,
|
|
858
|
+
// v0.6.13(问题 3):当前正在跑的子代理快照(由 onSubagentProgress 刷新)——
|
|
859
|
+
// 界面/日志据此显示"在等哪个子任务、它跑到第几轮 / 几次工具调用"
|
|
860
|
+
subagent: null,
|
|
698
861
|
forecast: null,
|
|
699
862
|
noProgressMs,
|
|
700
863
|
lastProgressAt: Date.now(),
|
|
701
864
|
progressReason: '回合开始',
|
|
702
865
|
stalled: false,
|
|
866
|
+
stallCause: null,
|
|
703
867
|
note: null,
|
|
704
868
|
zeroToolCalls: false,
|
|
705
869
|
};
|
|
@@ -711,26 +875,66 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
711
875
|
let noProgressFired = false;
|
|
712
876
|
// 本回合的"调用签名 → 次数":完全相同(name+args)的重复调用不算进展(见 finishTool)
|
|
713
877
|
const turnSigCounts = new Map();
|
|
714
|
-
/**
|
|
878
|
+
/**
|
|
879
|
+
* 继承来的"整棵树一起收口"截止时刻——**每次都重新取**,不是 spawn 那一刻的快照。
|
|
880
|
+
*
|
|
881
|
+
* 真机 bug(02:42–03:13,`status=stalled`):快照版把子代理的等待压到"父回合剩余时间",
|
|
882
|
+
* 子代理刚有进展(会话记录里写着「模型请求 15 轮、工具调用 18 次」)就被掐掉,
|
|
883
|
+
* 且文案按子代理自己的最后进展算时长 → 谎报"连续 1 分钟没有任何工具调用"。
|
|
884
|
+
* 现在父回合的 lastProgressAt 会被子代理的进展续期(见 markProgress / onSubagentProgress),
|
|
885
|
+
* 于是这个截止时刻**只前移不倒退**:真在干活就不收口,真停了才一起收口。
|
|
886
|
+
*/
|
|
887
|
+
const inheritedDeadlineAt = () => {
|
|
888
|
+
const snap = Number(cfg.turnProgressDeadlineAt) > 0 ? Number(cfg.turnProgressDeadlineAt) : 0;
|
|
889
|
+
const pp = /** @type {any} */ (cfg.parentProgress);
|
|
890
|
+
const live = pp && Number(pp.noProgressMs) > 0 && Number(pp.lastProgressAt) > 0 ? Number(pp.lastProgressAt) + Number(pp.noProgressMs) : 0;
|
|
891
|
+
const d = Math.max(snap, live);
|
|
892
|
+
return d > 0 ? d : null;
|
|
893
|
+
};
|
|
894
|
+
/**
|
|
895
|
+
* 看门狗到点。`cause` 必须如实区分(问题 3/4):
|
|
896
|
+
* · 'own' —— 本回合自己连续 N 没有真实进展;
|
|
897
|
+
* · 'inherited' —— 父回合已判定整轮无进展,子代理随整棵树收口(**不是**子代理卡住)。
|
|
898
|
+
*/
|
|
899
|
+
// 触发原因由 armNoProgress 在**安排计时器前**写入(而不是当参数传进 fireNoProgress):
|
|
900
|
+
// `noProgressTimer = setTimeout(fireNoProgress, left);` 这一行的形状被 §139 与 batch28 ① 共同钉住,
|
|
901
|
+
// 不能为了多带一个参数去改它(那会让既有变异验证变成"变异点未找到"——本轮实际踩到过)。
|
|
902
|
+
let noProgressCause = /** @type {'own'|'inherited'} */ ('own');
|
|
715
903
|
const fireNoProgress = () => {
|
|
904
|
+
const cause = noProgressCause;
|
|
716
905
|
noProgressFired = true;
|
|
717
906
|
const waited = Date.now() - turnProgress.lastProgressAt;
|
|
718
907
|
turnProgress.stalled = true;
|
|
719
|
-
turnProgress.
|
|
908
|
+
turnProgress.stallCause = cause;
|
|
909
|
+
turnProgress.phase = cause === 'inherited' ? '已中止(父回合无进展)' : '已中止(无进展)';
|
|
720
910
|
turnProgress.waitedMs = waited;
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
911
|
+
const pt = turnProgress.pendingTool;
|
|
912
|
+
turnProgress.note =
|
|
913
|
+
cause === 'inherited'
|
|
914
|
+
? inheritedStopNotice({ waitedMs: waited, toolCalls: turnProgress.toolCalls, modelRounds: turnProgress.modelRounds })
|
|
915
|
+
: noProgressNotice({
|
|
916
|
+
waitedMs: waited,
|
|
917
|
+
toolCalls: turnProgress.toolCalls,
|
|
918
|
+
modelRounds: turnProgress.modelRounds,
|
|
919
|
+
unproductive: turnProgress.unproductive,
|
|
920
|
+
// 问题 4:必须说清"在等哪个工具、等了多久",否则用户无从判断该调什么
|
|
921
|
+
pendingTool: pt ? { name: String(pt.name || ''), ms: Date.now() - Number(pt.startedAt || Date.now()) } : null,
|
|
922
|
+
subagent: turnProgress.subagent,
|
|
923
|
+
});
|
|
727
924
|
try { io.print(style(turnProgress.note, C.yellow)); } catch {}
|
|
728
925
|
try { currentAc?.abort(new Error(`无进展 ${Math.max(1, Math.round(waited / 60000))} 分钟,已中止`)); } catch {}
|
|
729
926
|
};
|
|
730
927
|
const armNoProgress = () => {
|
|
731
928
|
if (noProgressTimer) clearTimeout(noProgressTimer);
|
|
732
|
-
|
|
733
|
-
const
|
|
929
|
+
const ownDeadline = turnProgress.lastProgressAt + noProgressMs;
|
|
930
|
+
const inh = inheritedDeadlineAt();
|
|
931
|
+
// 正等子代理时给一点点宽限,保证**子代理自己的看门狗先响**并把原因回传父回合(见常量注释)。
|
|
932
|
+
const grace = turnProgress.subagent && turnProgress.pendingTool ? SUBAGENT_WAIT_GRACE_MS : 0;
|
|
933
|
+
// 继承的截止时刻只**提前**("一整棵树一起收口");自己的判据之外只多那点宽限。
|
|
934
|
+
const useInherited = inh != null && inh < ownDeadline;
|
|
935
|
+
const target = useInherited ? inh : ownDeadline + grace;
|
|
936
|
+
const left = Math.max(1000, target - Date.now());
|
|
937
|
+
noProgressCause = useInherited ? 'inherited' : 'own';
|
|
734
938
|
noProgressTimer = setTimeout(fireNoProgress, left);
|
|
735
939
|
try { noProgressTimer.unref?.(); } catch {} // 不因为这个计时器拖住进程退出
|
|
736
940
|
};
|
|
@@ -744,10 +948,46 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
744
948
|
return turnProgress.zeroToolCallsNote;
|
|
745
949
|
};
|
|
746
950
|
/** 有真实进展才续期:工具结果返回 / 模型正文或推理增量。 */
|
|
951
|
+
let lastSubNotifyAt = 0;
|
|
747
952
|
const markProgress = (/** @type {string} */ why) => {
|
|
748
|
-
|
|
953
|
+
const now = Date.now();
|
|
954
|
+
turnProgress.lastProgressAt = now;
|
|
749
955
|
turnProgress.progressReason = why;
|
|
750
956
|
armNoProgress();
|
|
957
|
+
// v0.6.13(问题 3 的核心修法):**子代理的进展必须算父回合的进展**。
|
|
958
|
+
// 真机现场:父回合第 12 轮派出两个只读子代理,子代理在 10 分钟里跑了 15 轮 / 18 次工具调用
|
|
959
|
+
// (会话记录原文),父回合却只看得到"正在等待工具 task(已 620s)"→ 看门狗判"整轮无进展"
|
|
960
|
+
// 把父子一起掐了。父回合此刻并不能从工具结果里拿到任何中间信号,所以进展必须由子代理
|
|
961
|
+
// **主动回传**(onSubagentProgress)——父回合据此续期,并把快照挂到 turnProgress.subagent,
|
|
962
|
+
// 让界面/日志能写出"在等哪个子任务、它跑到第几轮"。
|
|
963
|
+
// 节流 1s:增量帧可能成百上千,父回合的续期只需"还在动"这个事实。
|
|
964
|
+
// 例外:**工具结果**是低频且高价值的进展信号("子代理跑到第几步"就靠它),一律照原样回传。
|
|
965
|
+
const isToolStep = /^工具 /.test(why);
|
|
966
|
+
if (typeof cfg?.onSubagentProgress === 'function' && (isToolStep || now - lastSubNotifyAt >= 1000)) {
|
|
967
|
+
lastSubNotifyAt = now;
|
|
968
|
+
try {
|
|
969
|
+
cfg.onSubagentProgress({
|
|
970
|
+
label: String(cfg?.subagentLabel || ''),
|
|
971
|
+
modelRounds: turnProgress.modelRounds,
|
|
972
|
+
toolCalls: turnProgress.toolCalls,
|
|
973
|
+
reason: why,
|
|
974
|
+
});
|
|
975
|
+
} catch {}
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
/** 子代理进展 → 本回合续期 + 记录快照(供 UI/日志"在等哪个子任务、它到哪一步了")。 */
|
|
979
|
+
turnProgress.onSubagentProgress = (/** @type {any} */ info) => {
|
|
980
|
+
const label = String(info?.label || '');
|
|
981
|
+
const rounds = Number(info?.modelRounds) || 0;
|
|
982
|
+
const calls = Number(info?.toolCalls) || 0;
|
|
983
|
+
// 只前进不后退:并行子代理里"后到的旧快照"不得把更靠前的进展覆盖回去
|
|
984
|
+
const prev = turnProgress.subagent;
|
|
985
|
+
if (!prev || rounds + calls >= (Number(prev.modelRounds) || 0) + (Number(prev.toolCalls) || 0)) {
|
|
986
|
+
turnProgress.subagent = { label, modelRounds: rounds, toolCalls: calls, at: Date.now(), reason: String(info?.reason || '') };
|
|
987
|
+
} else if (prev) {
|
|
988
|
+
prev.at = Date.now();
|
|
989
|
+
}
|
|
990
|
+
markProgress(`子代理进展${label ? `(${label})` : ''}`);
|
|
751
991
|
};
|
|
752
992
|
armNoProgress();
|
|
753
993
|
// ⚠ 两个 ctx,别混:
|
package/src/model-discovery.js
CHANGED
|
@@ -11,7 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
12
12
|
import { PROVIDERS, MODELS } from './models.js';
|
|
13
13
|
import { getStoredKey } from './credentials.js';
|
|
14
|
-
import { isLocalBaseUrl } from './model-caps.js';
|
|
14
|
+
import { isLocalBaseUrl, resolveModelCaps } from './model-caps.js';
|
|
15
15
|
import { resolveProviderConfig } from './providers/index.js';
|
|
16
16
|
|
|
17
17
|
const TTL_MS = 60 * 60 * 1000;
|
|
@@ -198,6 +198,17 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
|
|
|
198
198
|
ttftBaselineMs: null,
|
|
199
199
|
prefillBaselineMs: null,
|
|
200
200
|
slowdown: null,
|
|
201
|
+
// v0.6.13(问题 2):引擎自述上下文(本地端点读 GET /props)——读不到就是 null,**不猜**
|
|
202
|
+
engineCtx: null,
|
|
203
|
+
engineCtxSource: null,
|
|
204
|
+
engineProbed: false,
|
|
205
|
+
configuredWindow: (() => {
|
|
206
|
+
try {
|
|
207
|
+
return resolveModelCaps(cfg, modelName).contextWindow;
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
})(),
|
|
201
212
|
state: /** @type {string} */ ('unreachable'),
|
|
202
213
|
error: /** @type {string|null} */ (null),
|
|
203
214
|
note: '',
|
|
@@ -298,6 +309,37 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
|
|
|
298
309
|
info.note = `❌ 不可达:${info.error}${info.isLocal ? '(本地引擎没起来?先确认端口/进程)' : ''}`;
|
|
299
310
|
return info;
|
|
300
311
|
}
|
|
312
|
+
// ⑤ 引擎自述上下文(v0.6.13 问题 2):**本地端点**额外读一次 `GET /props`。
|
|
313
|
+
// · 放在 chat 探测之后、结论拼装之前:chat 通不通与"引擎装得下多少"是两件事;
|
|
314
|
+
// · 只打一次、只读不改(GET),失败/非 JSON/没有 n_ctx 一律视为"未读到",绝不编造;
|
|
315
|
+
// · llama.cpp 的 /props 挂在**根**上(`http://host:port/props`),而 baseUrl 通常带 `/v1`,
|
|
316
|
+
// 所以先试剥掉 `/v\d+` 的根路径,再退回 baseUrl 原样(兼容把 /props 挂在前缀下的网关)。
|
|
317
|
+
// · **门槛**:只有引擎**自报了模型名**(`info.loadedModel`)才多打这一个请求。理由有两条:
|
|
318
|
+
// ① llama.cpp 家族(含 llama-cpp-python/koboldcpp/LM Studio)总会在响应里回模型名/文件路径,
|
|
319
|
+
// 回不了名的多半是"只实现了 /v1/chat/completions"的网关或测试桩——它们不认识 /props;
|
|
320
|
+
// ② 实测教训:`test/e2e-web.js` 的 mock 只按 POST+JSON body 解析(收到空 body 的 GET 会抛
|
|
321
|
+
// `JSON.parse('')`),多打一个 GET 会把整条 e2e 打断——不认识 /props 的端点**一个字节都不该多发**。
|
|
322
|
+
// 跳过时结论里会写"跳过"(`engineContextNotice` 的 skipReason),不会假装核对过。
|
|
323
|
+
if (info.isLocal && info.loadedModel) {
|
|
324
|
+
info.engineProbed = true;
|
|
325
|
+
const strippedBase = base.replace(/\/v\d+$/, '');
|
|
326
|
+
const propsUrls = strippedBase !== base ? [`${strippedBase}/props`, `${base}/props`] : [`${base}/props`];
|
|
327
|
+
for (const u of propsUrls) {
|
|
328
|
+
try {
|
|
329
|
+
const res = await once(u, { method: 'GET', headers }, Math.min(5000, left()));
|
|
330
|
+
if (!res || !res.ok) continue;
|
|
331
|
+
const raw = String(await res.text()).slice(0, 65536);
|
|
332
|
+
const ctx = engineContextFromProps(JSON.parse(raw));
|
|
333
|
+
if (ctx != null) {
|
|
334
|
+
info.engineCtx = ctx;
|
|
335
|
+
info.engineCtxSource = u;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
/* 读不到就跳过(端点没实现 /props、返回非 JSON、超时)——结论里会如实写"未读到" */
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
301
343
|
info.ttftMs = median(info.ttftSamples);
|
|
302
344
|
const n = info.toolCallRuns;
|
|
303
345
|
info.toolState = n === 0 ? 'unknown' : info.toolCallHits === n ? 'stable-tools' : info.toolCallHits === 0 ? 'no-tools' : 'unstable-tools';
|
|
@@ -370,11 +412,21 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
|
|
|
370
412
|
`agent 任务可能步数为 0,**不要**据此判定"不支持工具调用"`;
|
|
371
413
|
const ttftNote = info.ttftMs != null ? `首帧 ${(info.ttftMs / 1000).toFixed(2)}s` : '首帧未测到';
|
|
372
414
|
const prefillNote = info.prefillMs != null ? `参考 prefill ${prefillTokens} tokens ${(info.prefillMs / 1000).toFixed(2)}s` : `参考 prefill 未测到`;
|
|
415
|
+
// v0.6.13(问题 2):引擎自述上下文 vs 配置——**必须在结论里**(界面 banner 与 web-server.log 都用这条 note)
|
|
416
|
+
const engineNote = engineContextNotice({
|
|
417
|
+
engineCtx: info.engineCtx,
|
|
418
|
+
configuredWindow: info.configuredWindow,
|
|
419
|
+
source: info.engineCtxSource,
|
|
420
|
+
probed: info.engineProbed,
|
|
421
|
+
isLocal: info.isLocal,
|
|
422
|
+
// 跳过的**具体**原因要写出来(否则"跳过"与"没读"分不清,等于没说)
|
|
423
|
+
skipReason: info.isLocal ? (info.loadedModel ? null : '该端点未自报模型名,不确认它实现 /props') : '非本地端点不预检 /props',
|
|
424
|
+
});
|
|
373
425
|
const slowNote =
|
|
374
426
|
(info.slowdown?.ttftRatio != null && info.slowdown.ttftRatio >= 3) || (info.slowdown?.prefillRatio != null && info.slowdown.prefillRatio >= 3)
|
|
375
427
|
? `\n ⚠ 与上次预检相比明显变慢(首帧 ${info.ttftBaselineMs != null ? (info.ttftBaselineMs / 1000).toFixed(2) + 's → ' : '?'}${info.ttftMs != null ? (info.ttftMs / 1000).toFixed(2) + 's' : '?'}):该端点**可能仍在处理上一个请求**(引擎 slot 被占/排队),建议重启引擎后再跑长任务。`
|
|
376
428
|
: '';
|
|
377
|
-
info.note = `${toolNote} · ${ttftNote} · ${prefillNote}${info.partial ? ' ·(预检超过时间预算,样本未跑满)' : ''} · ${nameNote}${slowNote}`;
|
|
429
|
+
info.note = `${toolNote} · ${ttftNote} · ${prefillNote}${info.partial ? ' ·(预检超过时间预算,样本未跑满)' : ''} · ${nameNote} · ${engineNote}${slowNote}`;
|
|
378
430
|
return info;
|
|
379
431
|
})();
|
|
380
432
|
probeInflight.set(key, run);
|
|
@@ -492,6 +544,79 @@ function prefillTokensLabel() {
|
|
|
492
544
|
return '2000 tokens';
|
|
493
545
|
}
|
|
494
546
|
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
// v0.6.13(问题 2):**引擎自述上下文** vs **配置上下文**
|
|
549
|
+
//
|
|
550
|
+
// 真机证据(~/.mingdao/logs/web-server.log:229,2026-10-09T02:41:48):
|
|
551
|
+
// chat 错误 t820945ac81c9ce5f [流式响应错误] insufficient memory: the request exceeded
|
|
552
|
+
// available GPU memory (sustained critical memory pressure during prefill … Reduce
|
|
553
|
+
// --context-window, close other apps, or try q8 KV quantization.
|
|
554
|
+
// 而 ~/.mingdao/config.json 里 customModels.MLocalModel3.6.2.contextWindow = 131072,
|
|
555
|
+
// 引擎(llama.cpp,127.0.0.1:60091)自述 n_ctx = 32768 —— 配置是引擎能力的 **4 倍**。
|
|
556
|
+
// 后果:内核按 131072 推导预算(98,304),把 15,983 tokens 的 prompt 发给一个只装了 32k 的
|
|
557
|
+
// 引擎,长上下文**在 prefill 阶段就被引擎直接拒绝**——此时换预设、调 contextBudget、
|
|
558
|
+
// 调 timeout.firstTokenMs 全都无济于事(那些都改不了引擎的 n_ctx)。这正是负责人"换预设也没用"
|
|
559
|
+
// 的根因,所以它必须在预检阶段就被读出来、并且**在日志与界面上各说一次**。
|
|
560
|
+
//
|
|
561
|
+
// 读法:llama.cpp 的 `GET /props` 返回 `default_generation_settings.n_ctx`(有的版本另有顶层
|
|
562
|
+
// `n_ctx` / `n_ctx_train`);其他引擎(vLLM 风格 / Ollama 风格 / 自研网关)能读多少读多少,
|
|
563
|
+
// 读不到就**跳过并说明**(不猜、不编造、不把它当成"引擎不行")。
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
/** 从引擎自述(/props 的 JSON)里抽"真实上下文窗口"(纯函数,便于断言/变异)。
|
|
567
|
+
*
|
|
568
|
+
* 候选字段按"越贴近引擎真实装载值越靠前"排序:
|
|
569
|
+
* · llama.cpp /props:`default_generation_settings.n_ctx`(服务端实际 n_ctx)、顶层 `n_ctx`、`n_ctx_train`;
|
|
570
|
+
* · vLLM 风格:顶层 `max_model_len`;
|
|
571
|
+
* · 通用/自研:`context_length`、`model_info.*.context_length`(Ollama /api/show 的形态)。
|
|
572
|
+
* 一律要求**正整数**(0/负数/字符串/NaN 视为读不到)——绝不拿一个假值去和配置比。 */
|
|
573
|
+
export function engineContextFromProps(/** @type {any} */ json) {
|
|
574
|
+
if (!json || typeof json !== 'object') return null;
|
|
575
|
+
const dgs = json.default_generation_settings || {};
|
|
576
|
+
const candidates = [
|
|
577
|
+
dgs.n_ctx,
|
|
578
|
+
json.n_ctx,
|
|
579
|
+
dgs.params?.n_ctx,
|
|
580
|
+
json.n_ctx_train,
|
|
581
|
+
json.max_model_len,
|
|
582
|
+
json.context_length,
|
|
583
|
+
json.model_info?.context_length,
|
|
584
|
+
...Object.values(json.model_info || {}).map((/** @type {any} */ v) => v?.context_length),
|
|
585
|
+
];
|
|
586
|
+
for (const c of candidates) {
|
|
587
|
+
const n = Number(c);
|
|
588
|
+
if (Number.isFinite(n) && n > 0) return Math.round(n);
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 「引擎自述上下文 vs 配置」的**用户可见结论**(纯函数,日志/界面 banner/规模预告共用同一份文案)。
|
|
595
|
+
*
|
|
596
|
+
* 三态(不合并、不省略):
|
|
597
|
+
* · 读到了且配置 ≤ 引擎 → 明确说"一致"(用户才知道这条核对**做过**了);
|
|
598
|
+
* · 读到了且配置 > 引擎 → **警告**:"配置超出引擎能力,长上下文将直接被引擎拒绝",
|
|
599
|
+
* 并点明"换预设/调 contextBudget 都改不了引擎上限"(负责人现场正是卡在这里);
|
|
600
|
+
* · 没读到(端点不提供 /props、字段缺失、非本地端点或不确认实现 /props 而跳过)→ 如实说"未读到/跳过",
|
|
601
|
+
* **不**据此判定端点不行,也不假装核对过。
|
|
602
|
+
* @param {{ engineCtx?: number|null, configuredWindow?: number|null, source?: string|null, probed?: boolean, isLocal?: boolean, skipReason?: string|null }} input
|
|
603
|
+
*/
|
|
604
|
+
export function engineContextNotice({ engineCtx = null, configuredWindow = null, source = null, probed = false, isLocal = false, skipReason = null } = {}) {
|
|
605
|
+
const eng = Number(engineCtx) > 0 ? Math.round(Number(engineCtx)) : null;
|
|
606
|
+
const conf = Number(configuredWindow) > 0 ? Math.round(Number(configuredWindow)) : null;
|
|
607
|
+
const where = source ? `(读自 ${source})` : '';
|
|
608
|
+
if (eng == null) {
|
|
609
|
+
if (!probed) {
|
|
610
|
+
return `引擎自述上下文:跳过(${skipReason || (isLocal ? '该端点未自报模型名,不确认它实现 /props' : '非本地端点不预检 /props')})——无法核对配置的 ${conf ?? '?'} 是否超出引擎能力`;
|
|
611
|
+
}
|
|
612
|
+
return `引擎自述上下文:**未读到**(该端点不提供 /props 或其中没有 n_ctx/default_generation_settings.n_ctx 字段)——无法核对配置的 ${conf ?? '?'} 是否超出引擎能力`;
|
|
613
|
+
}
|
|
614
|
+
if (conf != null && conf > eng) {
|
|
615
|
+
return `⚠ 引擎自述上下文 ${eng} vs 配置 ${conf} —— 配置超出引擎能力,长上下文将直接被引擎拒绝${where}。换预设/调小 config.contextBudget 都**改不了引擎上限**:要么在引擎侧把上下文窗口调大(llama.cpp 的 --ctx-size / --context-window),要么把配置改成 ≤ ${eng}。`;
|
|
616
|
+
}
|
|
617
|
+
return `引擎自述上下文 ${eng} vs 配置 ${conf ?? '(未声明)'} —— 配置未超出引擎能力${where}`;
|
|
618
|
+
}
|
|
619
|
+
|
|
495
620
|
export function modelCacheFile() {
|
|
496
621
|
return path.join(mingdaoHome(), 'model-cache.json');
|
|
497
622
|
}
|
package/src/web/app.js
CHANGED
|
@@ -454,6 +454,9 @@ async function send(){
|
|
|
454
454
|
if(ev.tokensPerSec!=null) bits.push('≈'+Number(ev.tokensPerSec).toFixed(1)+' tok/s');
|
|
455
455
|
if(ev.contentChars) bits.push('正文 '+ev.contentChars+' 字');
|
|
456
456
|
if(ev.pendingTool) bits.push('等待工具 '+ev.pendingTool.name+' 已 '+Math.round((ev.pendingTool.ms||0)/1000)+'s');
|
|
457
|
+
// v0.6.13(问题 3):等 `task` 子代理时把**子代理自己的进度**摆出来——
|
|
458
|
+
// 真机现场界面/日志只有"等待工具 task 已 620s",与"卡死 620s"完全同形。
|
|
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 前)':''));
|
|
457
460
|
if(ev.stalled) bits.push('已判定无进展,正在中止');
|
|
458
461
|
think.textContent='💭 正在工作 '+Math.floor(p/60)+' 分 '+Math.round(p%60)+' 秒 · 第 '+msg._steps+' 回合 · '+curPhase+' · '+bits.join(' · ')+'…';
|
|
459
462
|
renderWorkStatus(); }
|
|
@@ -474,7 +477,23 @@ async function send(){
|
|
|
474
477
|
// 这里把作废的确认收掉:该确认已经死了,界面上不该再留一个能点的假弹窗。
|
|
475
478
|
if(/权限确认超时/.test(String(ev.message||''))) dismissAskModals();
|
|
476
479
|
const d=document.createElement('div'); d.className='errline'; d.textContent=ev.message; msg.appendChild(d); scroll(); }
|
|
477
|
-
else if(ev.type==='done'){
|
|
480
|
+
else if(ev.type==='done'){
|
|
481
|
+
// v0.6.13(问题 4):把**结局**写进控制台。桌面壳的日志就是渲染进程的 console(转存到
|
|
482
|
+
// ~/Library/Application Support/mingdao-desktop/logs/mingdao.log),此前那里只有
|
|
483
|
+
// 「回合收尾:generating=false,按钮恢复发送」——同一时刻内核写的是 `status=stalled`,
|
|
484
|
+
// 事后取证完全对不上(真机 2026-10-09T03:13:16 两条日志并列就是证据)。
|
|
485
|
+
const endStatus=ev.stalled?(ev.stallCause==='inherited'?'stalled(父回合整轮无进展)':'stalled(长时间无进展)'):ev.aborted?'aborted':(ev.truncated?'capped':'done');
|
|
486
|
+
console.log('[MingDao] done 事件:session=' + ev.session + ' status=' + endStatus + ' durationMs=' + (ev.durationMs||0));
|
|
487
|
+
onActivity();
|
|
488
|
+
if(ev.budget){ const b=ev.budget; if(hintTextEl) hintTextEl.textContent='预算 '+Math.round(b.used/1000)+'K/'+Math.round(b.total/1000)+'K('+Math.round(b.used/b.total*100)+'%)· 本轮完成 · 提示栏右侧为今日费用与命中率'; }
|
|
489
|
+
refreshStatusBar();
|
|
490
|
+
if(ev.stats&&ev.stats.deliverables&&ev.stats.deliverables.length){ const card=document.createElement('div'); card.className='deliver'; card.innerHTML='<div class="t">📦 交付物('+ev.stats.deliverables.length+' 个文件)</div>'+ev.stats.deliverables.map(f=>'<div class="i">'+esc(f)+(f.toLowerCase().endsWith('.html')?' <span style="color:var(--accent2)">— 浏览器打开即可运行</span>':'')+'</div>').join(''); msg.appendChild(card); }
|
|
491
|
+
// v0.6.13(问题 4):stalled 必须**用户可见**,而且与 capped/aborted 区分开(复用既有 done.note 渲染)。
|
|
492
|
+
// 内核已发了一条同文案的告警 banner(服务端 `if (r.stalled) send({type:'banner'…})`),
|
|
493
|
+
// 这里再在消息下方留一条——用户滚动回看时不必去翻日志才知道"这轮为什么停了"。
|
|
494
|
+
if(ev.stalled){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent='⏹ 本轮因长时间无进展已中止(不是步数上限 capped、也不是你点的停止 aborted)——'+(ev.note||'详情见上方告警条。'); msg.appendChild(d); }
|
|
495
|
+
else if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); }
|
|
496
|
+
currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
|
|
478
497
|
});
|
|
479
498
|
}catch(e){ onActivity(); // 诊断(v0.4.2 network error 排查):静默中断此前无任何日志,无法区分
|
|
480
499
|
// 「手点停止 / 看门狗 / fetch 流静默断裂」。v0.4.3 修:第二参改 JSON 字符串——Electron 日志
|
package/src/web/server.js
CHANGED
|
@@ -24,7 +24,7 @@ import { createApiDispatch } from './routes/api.js';
|
|
|
24
24
|
import { ensureHome, loadConfig, saveConfig, mingdaoHome } from '../config.js';
|
|
25
25
|
import { setStoredKey, removeStoredKey, getStoredKey, maskKey } from '../credentials.js';
|
|
26
26
|
import { availableModels, fetchProviderModels, providerHasKey, probeEndpoint, adaptiveTimeouts } from '../model-discovery.js';
|
|
27
|
-
import { isLocalBaseUrl } from '../model-caps.js';
|
|
27
|
+
import { isLocalBaseUrl, resolveModelCaps } from '../model-caps.js';
|
|
28
28
|
import { createProvider, resolveProviderConfig, helperProvider, resolveVisionSupport } from '../providers/index.js';
|
|
29
29
|
import { MODELS, modelPreset, PROVIDERS } from '../models.js';
|
|
30
30
|
import { routeTask, routingConfig } from '../routing.js';
|
|
@@ -314,9 +314,22 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
314
314
|
let probe = null;
|
|
315
315
|
try {
|
|
316
316
|
probe = await probeEndpoint(cfg, m, { timeoutMs: 15000 });
|
|
317
|
+
// v0.6.13(问题 2):这行日志此前用 `probe.toolCalls` 判工具支持——该字段**从来不存在**
|
|
318
|
+
// (真机日志里恒为 `tools=未知`,见 web-server.log:48/169/303),预检结论等于没落日志。
|
|
319
|
+
// 现在取真实的四态 toolState,并把**引擎自述上下文 vs 配置**也写进来。
|
|
320
|
+
const toolText =
|
|
321
|
+
probe.toolState === 'stable-tools'
|
|
322
|
+
? `支持(${probe.toolCallHits}/${probe.toolCallRuns})`
|
|
323
|
+
: probe.toolState === 'unstable-tools'
|
|
324
|
+
? `不稳定(${probe.toolCallHits}/${probe.toolCallRuns})`
|
|
325
|
+
: probe.toolState === 'no-tools'
|
|
326
|
+
? `不返回 tool_calls(${probe.toolCallHits}/${probe.toolCallRuns})`
|
|
327
|
+
: '未知/不可判定';
|
|
317
328
|
srvlog(
|
|
318
329
|
`预检 ${m} ${probe.state} ttft=${probe.ttftMs == null ? 'n/a' : Math.round(probe.ttftMs) + 'ms'} ` +
|
|
319
|
-
`
|
|
330
|
+
`prefill2000=${probe.prefillMs == null ? 'n/a' : Math.round(probe.prefillMs) + 'ms'} ` +
|
|
331
|
+
`tools=${toolText} endpoint=${probe.endpoint || '(无)'} ` +
|
|
332
|
+
`引擎自述上下文=${probe.engineCtx == null ? '未读到' : probe.engineCtx} 配置=${probe.configuredWindow ?? '?'}`
|
|
320
333
|
);
|
|
321
334
|
} catch (/** @type {any} */ e) {
|
|
322
335
|
srvlog(`预检 ${m} 失败(忽略,按默认超时继续):${String(e?.message || e)}`);
|
|
@@ -547,6 +560,9 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
547
560
|
ttftMs: tp.ttftMs ?? null,
|
|
548
561
|
tokensPerSec: decodeS && tp.contentTokens ? tp.contentTokens / decodeS : null,
|
|
549
562
|
pendingTool: tp.pendingTool ? { name: tp.pendingTool.name, ms: Date.now() - tp.pendingTool.startedAt } : null,
|
|
563
|
+
// v0.6.13(问题 3):等 `task` 子代理时把子代理自己的进度也写进日志——
|
|
564
|
+
// 真机现场只有"正在等待工具 task(已 620s)",看不出子代理是在干活还是卡死。
|
|
565
|
+
subagent: tp.subagent ?? null,
|
|
550
566
|
})
|
|
551
567
|
);
|
|
552
568
|
};
|
|
@@ -572,6 +588,10 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
572
588
|
contentChars: tp?.contentChars ?? 0,
|
|
573
589
|
tokensPerSec: decodeS && tp?.contentTokens ? Number((tp.contentTokens / decodeS).toFixed(1)) : null,
|
|
574
590
|
pendingTool: tp?.pendingTool ? { name: tp.pendingTool.name, ms: Date.now() - tp.pendingTool.startedAt } : null,
|
|
591
|
+
// v0.6.13(问题 3):正在跑的子代理快照——界面状态条据此显示"子 Agent 已跑 N 轮 / M 次工具调用"
|
|
592
|
+
subagent: tp?.subagent
|
|
593
|
+
? { label: tp.subagent.label ?? '', modelRounds: tp.subagent.modelRounds ?? 0, toolCalls: tp.subagent.toolCalls ?? 0, ms: tp.subagent.at ? Date.now() - tp.subagent.at : null }
|
|
594
|
+
: null,
|
|
575
595
|
stalled: Boolean(tp?.stalled),
|
|
576
596
|
});
|
|
577
597
|
} catch {}
|
|
@@ -827,6 +847,31 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
827
847
|
noProgressCfg: cfg.noProgressTimeoutMs,
|
|
828
848
|
});
|
|
829
849
|
send({ type: 'banner', text: `🔎 本地端点预检:${probe.note}` });
|
|
850
|
+
// v0.6.13(问题 2):**配置超出引擎能力**必须是一条独立的告警 banner,而且要在开工前出现。
|
|
851
|
+
// 真机根因(web-server.log:229):配置 contextWindow=131072,引擎(llama.cpp)自述 n_ctx=32768,
|
|
852
|
+
// 长上下文在 prefill 阶段被引擎直接拒绝(insufficient memory);用户当时反复"换预设"无效——
|
|
853
|
+
// 因为预设只改 contextBudget,**改不了引擎的 n_ctx**。所以这条要说清"换预设也没用"。
|
|
854
|
+
try {
|
|
855
|
+
const capsNow = resolveModelCaps(cfg, runModel);
|
|
856
|
+
if (Number(probe.engineCtx) > 0 && Number(capsNow.contextWindow) > Number(probe.engineCtx)) {
|
|
857
|
+
send({
|
|
858
|
+
type: 'banner',
|
|
859
|
+
warn: true,
|
|
860
|
+
text:
|
|
861
|
+
`⚠ 引擎自述上下文 ${probe.engineCtx} vs 配置 ${capsNow.contextWindow} —— 配置超出引擎能力,长上下文将直接被引擎拒绝` +
|
|
862
|
+
`(真机现场报的是 insufficient memory / 显存不足)。换预设、调 config.contextBudget、调 timeout.* 都**改不了引擎上限**:` +
|
|
863
|
+
`要么在引擎侧把上下文窗口调大(llama.cpp 的 --ctx-size / --context-window),要么把 customModels.${runModel}.contextWindow 改成 ≤ ${probe.engineCtx}。`,
|
|
864
|
+
});
|
|
865
|
+
} else if (probe.engineProbed && !(Number(probe.engineCtx) > 0)) {
|
|
866
|
+
// 真的读了却没读到 → 也要说一句("读不到就跳过并说明"),否则用户会以为内核核对过引擎能力了。
|
|
867
|
+
// 条件是 engineProbed:压根没读(非本地端点 / 端点未自报模型名)时不刷这条噪音,
|
|
868
|
+
// 那种情形的原因已经写在 probe.note 的"引擎自述上下文:跳过(…)"里。
|
|
869
|
+
send({
|
|
870
|
+
type: 'banner',
|
|
871
|
+
text: 'ℹ 引擎自述上下文:未读到(该端点不提供 /props 或其中没有 n_ctx 字段)——无法核对配置是否超出引擎能力;不影响继续使用。',
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
} catch {}
|
|
830
875
|
send({
|
|
831
876
|
type: 'banner',
|
|
832
877
|
text:
|
|
@@ -979,6 +1024,21 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
979
1024
|
const { resolveModelCaps, safeBudget } = await import('../model-caps.js');
|
|
980
1025
|
budgetInfo = { used, total: safeBudget(cfg, resolveModelCaps(cfg, runModel)) };
|
|
981
1026
|
} catch {}
|
|
1027
|
+
// v0.6.13(问题 4):**stalled 必须有用户可见的说明**。
|
|
1028
|
+
// 真机现场:内核写了 `chat 发送 done … status=stalled`,而桌面壳日志里只有
|
|
1029
|
+
// `[renderer] [MingDao] 回合收尾:generating=false,按钮恢复发送` —— 用户完全不知道
|
|
1030
|
+
// 这一轮是"跑满了步数(可续跑)""我点了停止"还是"再等下去也不会有新东西"。
|
|
1031
|
+
// 这里发一条**告警 banner**(与 done.note 同一份文案,单一来源就是 agent 的 r.note),
|
|
1032
|
+
// 前端 renderBanner 直接渲染;done.note 仍照旧带上,两条通道都不丢。
|
|
1033
|
+
if (r.stalled) {
|
|
1034
|
+
send({
|
|
1035
|
+
type: 'banner',
|
|
1036
|
+
warn: true,
|
|
1037
|
+
text:
|
|
1038
|
+
`⏹ 本轮因**长时间无进展**已中止(不是步数上限 capped、也不是你点的停止 aborted)。\n` +
|
|
1039
|
+
String(r.note || '').trim(),
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
982
1042
|
// v0.6.13(A):**"0 次工具调用"必须出现在收尾里**——否则端点不返回 tool_calls 时,
|
|
983
1043
|
// 界面看起来一切正常(ok:true + 正文),实际一次工具都没调("看起来在工作、其实什么都没做")。
|
|
984
1044
|
const zeroNote = String(r.perf?.zeroToolCalls || '');
|
|
@@ -993,6 +1053,9 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
993
1053
|
truncated: r.truncated,
|
|
994
1054
|
aborted: r.aborted,
|
|
995
1055
|
stalled: Boolean(r.stalled),
|
|
1056
|
+
// v0.6.13(问题 3/4):stalled 的**原因**也一并下发——'inherited' = 父回合整轮零进展
|
|
1057
|
+
//(子代理被一起收口),'own' = 本回合自己零进展。前端据此给出不同的处置建议。
|
|
1058
|
+
stallCause: r.perf?.stallCause ?? null,
|
|
996
1059
|
perf: r.perf ? { llmCalls: r.perf.llmCalls ?? null, toolCalls: r.perf.toolCalls ?? null, ttftMs: r.perf.ttftMs ?? null, noToolCalls: Boolean(r.perf.noToolCalls) } : null,
|
|
997
1060
|
note: [baseNote, zeroNote].filter(Boolean).join('\n'),
|
|
998
1061
|
stats: io.stats(),
|