mingdao-harness 0.6.17 → 0.6.19

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.
@@ -131,7 +131,7 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
131
131
  - `web/constants.js` —— 前端与服务端共享常量:并发上限 8(`MAX_CONCURRENT`)、附件数与体积上限、单文件读取上限 —— 数值只在此定义,防客户端预检/服务端校验/工具读取三处漂移。
132
132
  - `web/attachments.js` —— 聊天附件构造(纯函数):图片转 `data:` URL(仅视觉模型,单张 ≤5MB、最多 4 个),文本文件拼接进消息(≤200KB)—— 服务端按同一组常量校验。
133
133
  - `web/web-io.js` —— WebUI 的 io 适配器:把 Agent 的 io 事件翻译为 SSE 事件,权限确认/选择类交互经 `askHandler` 转发到浏览器模态框并等待 `POST /api/permission` 应答。
134
- - `web/util.js` / `web/app.js` —— 前端:`util.js` 是零状态工具(选择器/转义/Markdown/格式化),`app.js` 是页面主脚本(消息渲染与轨迹、设置面板、自绘气泡与模态框)—— 地址带 `?token=` 时记入 `sessionStorage` 并从地址栏移除,此后请求统一附加 `X-MingDao-Token`。
134
+ - `web/util.js` / `web/app.js` / `web/i18n.js` —— 前端:`util.js` 是零状态工具(选择器/转义/Markdown/格式化),`app.js` 是页面主脚本(消息渲染与轨迹、设置面板、自绘气泡与模态框),`i18n.js` 是零依赖 UI 文案表(zh/en 同键、默认跟随 `navigator.language`、`localStorage` 覆盖)—— 地址带 `?token=` 时记入 `sessionStorage` 并从地址栏移除,此后请求统一附加 `X-MingDao-Token`。
135
135
 
136
136
  ### 2.8 CLI 层(13)
137
137
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  > 自动更新包的**来源签名**是**另一件事**(防"更新源被投毒",不是防 SmartScreen),见 §五:
8
8
  > 代码已就位且判据 fail-closed,但**发布链路尚未接入**——默认档为 `warn-and-install`
9
- > (照旧安装,但 UI/日志明确写出"本次更新未验证来源签名")。
9
+ > (照旧安装,弹窗只给两行「未验证来源签名 + 怎么变严」,完整说明写进日志)。
10
10
 
11
11
  ---
12
12
 
@@ -102,9 +102,12 @@ macOS 作业的 `--mac dmg` 在检测到 `CSC_LINK` + Apple 凭证时自动完
102
102
  >
103
103
  > **本轮状态(诚实标注)**:代码已就位并 fail-closed,但**发布链路尚未接入签名**——
104
104
  > `desktop/gen-update-yml.mjs` 与服务器发布脚本都没有签名步骤,feed 里**没有** `.sig` 文件。
105
- > 因此客户端当前实际走的是**默认档 `warn-and-install`**:照旧安装,但在日志与 UI 里明确写出
106
- > 「**本次更新未验证来源签名**」。要让"没签名就拒绝"生效,发行方需要:① 按下面步骤接入签名;
107
- > ② 在客户端设 `MINGDAO_REQUIRE_UPDATE_SIGNATURE=1`(或改成默认值)。
105
+ > 因此客户端当前实际走的是**默认档 `warn-and-install`**:照旧安装,弹窗给两行——
106
+ > 「本次更新未验证来源签名(发布链路尚未接入签名)——已按默认策略安装。」+
107
+ > 「高安全场景可设 `MINGDAO_REQUIRE_UPDATE_SIGNATURE=1` 拒绝无签名更新;详情见日志与官网。」
108
+ > 完整解释(为什么无法用密码学证明来源、feed 被投毒时无法区分、不改配置与会话)写在**日志**里:
109
+ > 桌面版 `<userData>/logs/mingdao.log` 的 `更新来源校验:…` 行。要让"没签名就拒绝"生效,发行方需要:
110
+ > ① 按下面步骤接入签名;② 在客户端设 `MINGDAO_REQUIRE_UPDATE_SIGNATURE=1`(或改成默认值)。
108
111
 
109
112
  ### 5.1 为什么不是"再算一次 sha512"
110
113
 
@@ -154,7 +157,7 @@ node scripts/update-sign.mjs --verify desktop/dist/mingdao-setup-<ver>-x64.exe -
154
157
  | --- | --- | --- |
155
158
  | 有签名 + 验签通过 | `install` | 提示"来源签名校验通过(ed25519 · keyId=…)",正常安装 |
156
159
  | **有签名 + 验签失败 / 拿不到证据** | `reject` | **一律拒绝**:UI"更新包来源不可信,已拒绝"+ 引导官网手动下载;并**关闭 `autoInstallOnAppQuit`**(否则退出应用时照样装) |
157
- | 无签名 + 默认 | `warn-and-install` | 照旧安装,但日志与 UI 都写明"**本次更新未验证来源签名**"、并说明当前发布链路没有签名步骤 |
160
+ | 无签名 + 默认 | `warn-and-install` | 照旧安装;弹窗**两行**("未验证来源签名…已按默认策略安装" + "可设 `REQUIRE=1` 拒绝;详情见日志与官网"),完整解释写进日志行 |
158
161
  | 无签名 + `MINGDAO_REQUIRE_UPDATE_SIGNATURE=1` | `reject` | fail-closed:发行方/高安全用户的开关 |
159
162
 
160
163
  **为什么不默认 reject**:当前发布链路还没有签名,默认 reject 等于一夜之间掐死所有存量用户的自动更新。
package/docs/CONFIG.md CHANGED
@@ -29,6 +29,27 @@
29
29
  `stream_options` 时设 `false`)、`autoTitle`(自动生成会话标题,默认开)、`notify`(任务桌面通知,默认开)、
30
30
  `autoCompact`(上下文自动压缩,默认开,见下)、`audit`(工具调用审计,默认开,见下)。
31
31
 
32
+ ### WebUI 设置项的「默认值」列(只读,不落盘)
33
+
34
+ 设置面板每个可改项显示 **默认 / 当前 / 影响** 三列。默认值来自 `src/config.js` 的内置默认表,
35
+ `GET /api/state` 以**只读字段 `defaults`** 透出(不接受写入,也不参与任何判定):
36
+
37
+ ```json
38
+ "defaults": {
39
+ "permission": "ask", "sandbox": "off", "routing": false,
40
+ "contextBudget": 128000, "timeout": { "firstTokenMs": null, "streamIdleMs": null, "totalMs": null },
41
+ "autostart": false, "notify": true, "reasoningEffort": "high",
42
+ "theme": "system", "language": "auto"
43
+ }
44
+ ```
45
+
46
+ - `timeout.*` 为 `null` 表示**按模型自适应**(与「留空」同义)。
47
+ - `theme` / `language` 是浏览器侧偏好:`theme` 落 `mingdao-theme`、`language` 落 `mingdao-lang`;
48
+ `language: "auto"` 表示跟随 `navigator.language`。
49
+ - 「影响」一句话写在前端映射表(`src/web/app.js` 的 `SETTING_IMPACT`,英文文案在
50
+ `src/web/i18n.js` 的 `impact.*`),说明「改了会怎样」,例如改小 `contextBudget` 会减少可容纳历史、
51
+ 长任务更易触发压缩。
52
+
32
53
  ## 工具调用审计(P3-5)
33
54
 
34
55
  每个工具调用(含被拒/被钩子阻止/参数解析失败)自动落 `~/.mingdao/audit.jsonl`(600 权限):
@@ -552,6 +573,11 @@ API id,不参与 `modelLabels`(其条目仍带 `id`,等于自定义模型
552
573
  模型兜底 **32k**、远程兜底 **128k**。
553
574
  - **安全预算**:`contextBudget` 会被自动收紧到 `min(contextBudget, 窗口×75%, 窗口−maxOutput−余量)`,
554
575
  prompt 永不逼近窗口边缘(75% 舒适区以上 prefill 时间陡增)。
576
+ - **按引擎自述 `n_ctx` clamp(v0.6.18 §3.74)**:预检(`GET /props`)读到引擎真实 `n_ctx` 后,
577
+ 若预算超过 `n_ctx × 0.8`,**发请求前**就 clamp 到该上限并打印
578
+ `⚠ 引擎自述上下文 32768:config.contextBudget 131072 超过其 80%(26214),发请求前已自动 clamp …`。
579
+ 这是为了避免"明知引擎装不下还照发"——那种请求只会在 prefill 阶段被 `insufficient memory` 拒绝。
580
+ 想真正用更大上下文,必须调引擎侧 `--ctx-size`,改 `config.contextBudget` 只会被 clamp。
555
581
  - **边缘检测**:模型每轮上报真实 `prompt_tokens`,≥ 窗口 85% 时下一轮强制压缩历史(即使启发式
556
582
  计数低估也强制触发)。
557
583
  - **工具输出截断**:单条工具结果按 `窗口/16` 封顶(最少 2000 字),小窗口不再整条回灌大段代码。
@@ -729,6 +755,37 @@ request failed.` 同一错误此前在 60091 的 35B 上也出现过 → **不
729
755
  3. 临时把 `localConcurrency` 保持 `1`,并减少子代理数量(读只读子代理在本地模型下已不并行);
730
756
  4. **不要**因为 `engine shed its caches and stays up` 去重启引擎——它没有崩;重启只会重新加载模型。
731
757
 
758
+ #### 内存/显存不足时的自动降级重试(v0.6.18 §3.74)
759
+
760
+ 即使有串行化,本地引擎仍可能因**子代理并发 / 长解码 / 其他进程占显存**在 prefill 时拒绝请求:
761
+
762
+ ```
763
+ insufficient memory: the request exceeded available GPU memory
764
+ (sustained critical memory pressure during prefill; aborted before the allocator wall).
765
+ The engine shed its caches and stays up; this request failed.
766
+ ```
767
+
768
+ 真机(v0.6.17,8081 qwen)当时上下文只有 ≈2,773 tokens、预算 32,768——**整回合却直接失败**。
769
+ 现在对**本地端点**(`isLocalBaseUrl` 为真,或 `customModels.<名>.local: true`)改为**一次**有界降级重试:
770
+
771
+ | 动作 | 数值 |
772
+ | --- | --- |
773
+ | 压缩上下文预算 | 本回合 `contextBudget × 0.6`,**下限 4096**(例:32,768 → ~19,660) |
774
+ | 降低输出上限 | 本回合 `maxOutputTokens × 0.5`(例:8,192 → 4,096) |
775
+ | 串行化加强 | 该重试传 `localConcurrency: 1`(复用既有的 `acquireLocalSlot`),即便你配了 0/2 |
776
+ | 重试次数 | **最多一次**;第二次仍失败即如实报错,绝不无限重试 |
777
+
778
+ - **可见性**:界面与日志出现
779
+ `♻ 因本地引擎内存不足,已压缩上下文(32,768 → ~19,660)…并重试一次`;
780
+ 回合成功时 `done.note` 追加压缩前后数值、重试次数与最终结果(`↩ 内存降级重试(1 次)…重试后成功`)。
781
+ - **第二次仍失败**:回合失败,错误文案带压缩前后数值与具体建议——把 `config.contextBudget` 调到多少、
782
+ 关闭其他占用显存的模型进程/并行会话、引擎侧调大 `--ctx-size` 或使用 q8 KV 量化。
783
+ - **云端端点不重试**:云 API 的 507/容量问题不是本地上下文能改的,压缩重试只会多烧一次请求;
784
+ 400/401/429 等非内存错误同样不触发该恢复。
785
+ - **判据单一来源**:`isMemoryPressureError`(协议层:507 / `insufficient memory` / `memory_refusal` /
786
+ `allocator wall` / `engine shed its caches and stays up` / OOM 措辞)+
787
+ `describeUpstreamError(err, { isLocal })` 的 `recoverableByShrinking` 才决定是否重试。
788
+
732
789
  ### 用本地模型测 harness(测试者步骤清单)
733
790
 
734
791
  > 这一段是**操作清单**,不是原理说明。按顺序做,能在几分钟内区分"端点不行"和"harness 不行"。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.17",
3
+ "version": "0.6.19",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -8,7 +8,9 @@ import { compactConversation } from './compact.js';
8
8
  // 两个对象从此分开:内核 ctx(makeCtx() 的产物)只在循环内用;工具只拿到 makeToolCtx() 的白名单对象。
9
9
  import { buildToolSchemas, dispatch, makeToolCtx } from './tools/index.js';
10
10
  import { DEFAULT_MODEL, modelPreset } from './models.js';
11
- import { resolveModelCaps, safeBudget, EDGE_RATIO } from './model-caps.js';
11
+ import { resolveModelCaps, safeBudget, EDGE_RATIO, isLocalBaseUrl } from './model-caps.js';
12
+ // v0.6.18 §3.74:预检已读到引擎自述 n_ctx 时,**发请求前**按 n_ctx×0.8 clamp 预算(纯函数)。
13
+ import { clampContextBudgetToEngine } from './model-discovery.js';
12
14
  import { makeTokenCounter } from './tokenizer.js';
13
15
  import { createHooks } from './hooks.js';
14
16
  import { createIO, style, C } from './ui.js';
@@ -18,7 +20,7 @@ import { redactSecrets } from './redact.js';
18
20
  import { checkCostGuard, costGuardConfig, todayCost, preflightBlockMessage, roundGuardAction } from './cost-guard.js';
19
21
  import { recordCacheStats, packDailyCost } from './cachestats.js';
20
22
  import { estimateCost, cacheSplit, isPeakHour } from './pricing.js';
21
- import { resolveProviderConfig, createProvider, localGateSnapshot, localConcurrencyLimit } from './providers/index.js';
23
+ import { resolveProviderConfig, createProvider, localGateSnapshot, localConcurrencyLimit, isMemoryPressureError } from './providers/index.js';
22
24
  import { compileConstraints, checkPreTool, checkPostTool, checkOutput, blockedOutputText } from './constraints.js';
23
25
  import { createLedger, newRunId } from './ledger.js';
24
26
  import { registerEgressSink } from './net-guard.js';
@@ -77,6 +79,13 @@ export const DEFAULT_NO_PROGRESS_TIMEOUT_MS = 3600000;
77
79
  * 形态下多等 15s(相对默认 60 分钟是 0.4%),换"子代理自己的结论能回传父回合"。 */
78
80
  export const SUBAGENT_WAIT_GRACE_MS = 15000;
79
81
 
82
+ /** 本地引擎内存不足时允许的**降级重试次数**(v0.6.18 §3.74)。
83
+ *
84
+ * 只允许一次:内存墙不是网络抖动——同一份大 prompt 重发只会再撞一次。第一次失败后
85
+ * agent 会压缩上下文 + 输出减半 + 强制串行;第二次仍失败就如实报错,绝不无限重试。
86
+ * 抽成常量是为了让"最多一次"可被断言/变异钉住(不是散在 if 里的魔法值)。 */
87
+ export const MAX_MEMORY_RETRIES = 1;
88
+
80
89
  /** 解析无进展阈值:非正/非有限一律回落默认(fail-safe,防"配置写 0 就静默失去看门狗")。 */
81
90
  export function resolveNoProgressTimeoutMs(/** @type {any} */ cfg) {
82
91
  const v = Number(cfg?.noProgressTimeoutMs);
@@ -281,13 +290,20 @@ export function zeroToolCallNotice({ modelRounds, steps = 0, taskLike = false })
281
290
  /**
282
291
  * 上游/引擎错误的可操作解读(纯函数):把「HTTP 400 + 一段 Jinja/模板报错」这类原文
283
292
  * 变成「原文 + 这是什么 + 该怎么办」。识别不出时 hint 为 null(**绝不改原文**)。
293
+ *
294
+ * v0.6.18 §3.74:额外返回 `recoverableByShrinking`——**只有本地端点**的内存/显存不足
295
+ * 才是"压缩上下文 + 降输出 + 串行重试一次"能救的;云端 API 的 507/内存报错不在此列
296
+ * (云端的容量/配额不是本地上下文能改的,对云 API 做压缩重试只会多烧一次请求)。
284
297
  * @param {any} err
285
- * @returns {{ message: string, hint: string|null, status: number|null }}
298
+ * @param {{ isLocal?: boolean }} [opts]
299
+ * @returns {{ message: string, hint: string|null, status: number|null, recoverableByShrinking: boolean }}
286
300
  */
287
- export function describeUpstreamError(/** @type {any} */ err) {
301
+ export function describeUpstreamError(/** @type {any} */ err, opts = {}) {
288
302
  const raw = String(err?.message ?? err ?? '').trim();
289
303
  const status = Number(err?.status) > 0 ? Number(err.status) : null;
290
304
  const t = raw.toLowerCase();
305
+ const memoryPressure = isMemoryPressureError(err);
306
+ const recoverableByShrinking = Boolean(memoryPressure && opts?.isLocal === true);
291
307
  /** @type {string|null} */
292
308
  let hint = null;
293
309
  if (/jinja|template|no user query|parser for this template/.test(t)) {
@@ -321,7 +337,7 @@ export function describeUpstreamError(/** @type {any} */ err) {
321
337
  }
322
338
  // 上游原文可能很长(内核只保留前 400 字):如实标注它被截断过,避免用户以为这就是全部
323
339
  const truncated = /…|\.\.\.$/.test(raw) || raw.length >= 380;
324
- return { message: raw, hint: hint ? `💡 ${hint}${truncated ? '(上面的上游原文可能被截断)' : ''}` : null, status };
340
+ return { message: raw, hint: hint ? `💡 ${hint}${truncated ? '(上面的上游原文可能被截断)' : ''}` : null, status, recoverableByShrinking };
325
341
  }
326
342
 
327
343
  /**
@@ -436,7 +452,17 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
436
452
  const caps = resolveModelCaps(cfg, modelName);
437
453
  // safeBudget 恒用:即使用户显式 contextBudget 也套「窗口−输出−余量」上限与 75% 舒适区,
438
454
  // 防显式值撑爆窗口(本地模型窗口可能只有 32k/131k,用户却留了默认 128000)。
439
- const budget = safeBudget(cfg, caps);
455
+ const safeB = safeBudget(cfg, caps);
456
+ // v0.6.18 §3.74(自适应上下文):预检若已读到**引擎自述 n_ctx**,配置预算再大也没用——
457
+ // 发出去就会在 prefill 阶段被引擎以 insufficient memory 拒绝。这里在**创建 agent 时**
458
+ // (即第一次请求之前)就 clamp 到 n_ctx×0.8,并在第一回合开始前打印一条明确提示。
459
+ const budgetClamp = clampContextBudgetToEngine({
460
+ contextBudget: safeB,
461
+ configuredBudget: Number(cfg?.contextBudget) > 0 ? Number(cfg.contextBudget) : null,
462
+ engineCtx: cfg?.endpointProbe?.engineCtx ?? null,
463
+ });
464
+ const budget = budgetClamp.budget ?? safeB;
465
+ const budgetClampNotice = budgetClamp.clamped ? budgetClamp.notice : '';
440
466
  // maxOutput 也按窗口封顶:显式配超大 maxOutputTokens 时,prompt(预算)+output 仍不得越过窗口
441
467
  // (预算已按 caps.maxOutputTokens 留余量,但显式值可能更大——此处兜底,防服务端截断/拒绝)
442
468
  const maxOutput = Math.min(
@@ -480,6 +506,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
480
506
  // (CLI/REPL 每次派子代理都漏计;README 主推的「多方向并行调研」场景漏计最重)。
481
507
  // runTurn 开始时指向本轮 usage,finally 清空;runTurn 之外调用 spawnTask 时为 null(安全跳过)。
482
508
  let currentUsage = /** @type {any} */ (null);
509
+ // v0.6.18 §3.74:本回合的「内存降级重试」记录(runTurn 内写、runTurn 包装层读)与本回合
510
+ // 必须进 done.note 的提示(clamp 提示 / 压缩重试提示)。声明在 runTurn 之外,包装层才能把
511
+ // 这些提示统一追加到**每一条** return 路径上(runTurn 内部有十几个 return,逐条补会漏)。
512
+ let lastMemoryRecovery = /** @type {any} */ (null);
513
+ let turnNotes = /** @type {string[]} */ ([]);
514
+ /** clamp 提示每个 agent 只打一次(子代理各自持有一份,但只在各自第一回合出现)。 */
515
+ let clampNoticeShown = false;
483
516
  // v0.6.0 C1:当前回合的账本写入器(与 currentUsage 同款:runTurn 内赋值、finally 清空)。
484
517
  // 账本不可用/被关闭时它是 no-op,**绝不影响主流程**——与 writeAudit 同款容错。
485
518
  let turnLedger = /** @type {any} */ (null);
@@ -746,9 +779,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
746
779
  };
747
780
  }
748
781
 
749
- async function runTurn(/** @type {any} */ messages) {
782
+ async function runTurnInner(/** @type {any} */ messages) {
750
783
  let steps = 0;
751
784
  let finish = null;
785
+ // v0.6.18 §3.74:本回合实际生效的 prompt 预算 / 输出上限——内存不足时**只在本回合内**
786
+ // 压小(contextBudget ×0.6、maxOutputTokens ×0.5),不写回用户配置。
787
+ let turnBudget = budget;
788
+ let turnMaxOutput = maxOutput;
789
+ /** 本回合已用掉的内存降级重试次数(上限 MAX_MEMORY_RETRIES,默认 1)。 */
790
+ let memoryRetries = 0;
791
+ lastMemoryRecovery = null;
752
792
  /** v0.6.3(M-16):本回合是否出现过"上游提前关流"(回答可能不完整)。
753
793
  * 刻意不叫 truncated——既有 `truncated` 的含义是"步数兜底总结也失败了",两者必须分开。 */
754
794
  let upstreamTruncated = false;
@@ -1163,7 +1203,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1163
1203
  if (turnProgress.phase !== '已中止(无进展)') turnProgress.phase = '压缩上下文';
1164
1204
  const compacted = await compactConversation({
1165
1205
  messages,
1166
- budget,
1206
+ budget: turnBudget,
1167
1207
  count,
1168
1208
  provider,
1169
1209
  executorModel: subagentModel(cfg, modelName),
@@ -1203,7 +1243,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1203
1243
  }
1204
1244
  } catch {}
1205
1245
  }
1206
- const trimmed = trimMessages(messages, budget, count);
1246
+ const trimmed = trimMessages(messages, turnBudget, count);
1207
1247
 
1208
1248
  // reasoning 回填防护(Kimi P0 修正):带 tool_calls 的 assistant 消息必须「完整」回传
1209
1249
  // reasoning_content(DeepSeek thinking_mode 官方要求:后续请求含 tools 时缺/截断即 400,
@@ -1239,7 +1279,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1239
1279
  if (g && Number(g.dailyLimitYuan) > 0 && g.action !== 'downgrade') {
1240
1280
  let promptTokens = 0;
1241
1281
  for (const m of sanitized) promptTokens += messageTokens(m, count);
1242
- const worst = estimateCost(activeModel, promptTokens, maxOutput, null, new Date());
1282
+ const worst = estimateCost(activeModel, promptTokens, turnMaxOutput, null, new Date());
1243
1283
  // 判据本身抽到 cost-guard.js 的 preflightBlockMessage(v0.6.11 / P1-1 第一刀):
1244
1284
  // 它能被单测逐条钉边界,而 runTurn 只负责"拿到数字 → 问判据 → 该拦就拦"。
1245
1285
  // 统计不可读 / 无价格数据时判据返回 null(不误拦、也不静默放行——主检查会告警)。
@@ -1332,7 +1372,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1332
1372
  contextWindow: caps.contextWindow,
1333
1373
  isLocal: caps.isLocal,
1334
1374
  firstTokenMs: Number(cfg?.timeout?.firstTokenMs) > 0 ? Number(cfg.timeout.firstTokenMs) : effFirstTokenMs,
1335
- budget,
1375
+ budget: turnBudget,
1336
1376
  probe: cfg?.endpointProbe || null,
1337
1377
  localBusy: Boolean(caps.isLocal && (gate.active > 0 || gate.queued > 0)),
1338
1378
  localActive: gate.active,
@@ -1358,9 +1398,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1358
1398
  messages: sanitized,
1359
1399
  tools: toolsFor(readOnlyPhase, turnStrippedSet),
1360
1400
  temperature,
1361
- maxTokens: maxOutput,
1401
+ maxTokens: turnMaxOutput,
1362
1402
  // MiniMax P0:仅当 activeModel 声明支持 reasoning 才发送(否则 400 终止回合);'off' 显式禁用例外
1363
1403
  reasoningEffort: modelPreset(activeModel)?.supportsReasoning || reasoningEffort === 'off' ? reasoningEffort : undefined,
1404
+ // v0.6.18 §3.74:发生过内存降级后,本回合后续请求**一律压回单并发**(即便用户把
1405
+ // config.localConcurrency 配成 0/2)——provider 会把它传给 acquireLocalSlot。
1406
+ ...(memoryRetries > 0 ? { localConcurrency: 1 } : {}),
1364
1407
  signal: ac.signal,
1365
1408
  // v0.6.15(§3.59 第 1 条):本地端点串行化必须**在界面上可见**——
1366
1409
  // 排队时状态条/进度行写"排队等待本地端点(已串行化防内存压力)",
@@ -1440,6 +1483,68 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1440
1483
  // MacBook 本地 507 memory_refusal 根因(v0.4.5):服务端内存拒绝不是「空输出」——
1441
1484
  // 直接终结合合并透出降级提示,不计入空轮、不注入续写重试(内存未释放必再 507,空烧请求)。
1442
1485
  const e = /** @type {any} */ (err);
1486
+ // ---------------------------------------------------------------------------
1487
+ // v0.6.18 §3.74:**本地引擎内存/显存不足 → 压缩上下文 + 降输出 + 强制串行,重试一次**
1488
+ //
1489
+ // 真机(v0.6.17,8081 qwen):子代理并发/长解码导致显存峰值超限,引擎在 prefill 前拒绝,
1490
+ // 整回合直接失败;而当时上下文只有 ≈2,773 tokens、预算 32,768——压小预算/输出上限后
1491
+ // 极可能成功。这里不再立刻判失败,而是**只对本地端点**做一次有界降级重试:
1492
+ // · contextBudget ×0.6(下限 4k,避免压到连系统提示都放不下);
1493
+ // · maxOutputTokens ×0.5(只用于本回合后续请求);
1494
+ // · 该次重试传 localConcurrency=1(provider 的 acquireLocalSlot 会把并发上限压回 1);
1495
+ // · 只重试一次:memoryRetryUsed 一旦置真,第二次失败就如实抛错(不无限重试)。
1496
+ // 云端端点不在此列:云 API 的 507/容量问题不是本地上下文能改的,压缩重试只会多烧一次请求。
1497
+ // ---------------------------------------------------------------------------
1498
+ let localEndpoint = false;
1499
+ try {
1500
+ // 用**本回合实际使用**的模型判本地:费用护栏可能把 activeModel 降级到云端模型,
1501
+ // 此时不能拿原始 modelName 的 caps.isLocal 误判成"本地内存不足"。
1502
+ const activeCaps = resolveModelCaps(cfg, activeModel);
1503
+ const pcNow = resolveProviderConfig(cfg, activeModel);
1504
+ localEndpoint = Boolean(activeCaps.isLocal || isLocalBaseUrl(pcNow?.baseUrl || provider?.config?.baseUrl || ''));
1505
+ } catch {}
1506
+ const localMemory = describeUpstreamError(e, { isLocal: localEndpoint });
1507
+ if (localMemory.recoverableByShrinking) {
1508
+ if (memoryRetries >= MAX_MEMORY_RETRIES) {
1509
+ // 第二次仍失败:不掩盖,把压缩前后数值、重试次数、具体建议一起抛给用户/日志。
1510
+ const rec = /** @type {any} */ (lastMemoryRecovery) || {};
1511
+ const suggest = Math.max(4096, Math.floor(Number(rec.toBudget) > 0 ? Number(rec.toBudget) * 0.7 : 8192));
1512
+ const detail =
1513
+ `本地引擎内存不足:已压缩上下文(${fmtTok(rec.fromBudget)} → ~${fmtTok(rec.toBudget)})、` +
1514
+ `输出上限 ${fmtTok(rec.fromMaxOutput)} → ${fmtTok(rec.toMaxOutput)},并重试 1 次(最多一次),仍失败——不再重试。` +
1515
+ `建议:把 config.contextBudget 调到 ${fmtTok(suggest)} 左右(或更低),关闭其他占用显存的模型进程/并行会话,` +
1516
+ `并在引擎侧调大 --ctx-size 或使用 q8 KV 量化。`;
1517
+ try { io.print(style(`⚠ ${localMemory.message}\n ${detail}`, C.yellow)); } catch {}
1518
+ const enriched = new Error(`${localMemory.message}\n ${localMemory.hint || ''}\n ↩ ${detail}`);
1519
+ /** @type {any} */ (enriched).status = e?.status ?? 507;
1520
+ if (e?.headers !== undefined) /** @type {any} */ (enriched).headers = e.headers;
1521
+ throw enriched;
1522
+ }
1523
+ memoryRetries += 1;
1524
+ const fromBudget = turnBudget;
1525
+ const fromMaxOutput = turnMaxOutput;
1526
+ // 压缩比例 0.6、下限 4096:0.6 在真机 32,768 → ~19,660 的区间内足够把 KV cache 峰值压下来;
1527
+ // 再低会伤任务质量,且 4k 以下连系统提示 + 一轮工具结果都放不下(重启永远失败)。
1528
+ turnBudget = Math.max(4096, Math.floor(fromBudget * 0.6));
1529
+ turnMaxOutput = Math.max(256, Math.floor(fromMaxOutput * 0.5));
1530
+ lastMemoryRecovery = {
1531
+ attempts: memoryRetries,
1532
+ fromBudget,
1533
+ toBudget: turnBudget,
1534
+ fromMaxOutput,
1535
+ toMaxOutput: turnMaxOutput,
1536
+ at: Date.now(),
1537
+ };
1538
+ const msg =
1539
+ `♻ 因本地引擎内存不足,已压缩上下文(${fmtTok(fromBudget)} → ~${fmtTok(turnBudget)})、` +
1540
+ `输出上限 ${fmtTok(fromMaxOutput)} → ${fmtTok(turnMaxOutput)},并重试一次(串行化:并发上限 1)`;
1541
+ try { io.print(style(msg, C.yellow)); } catch {}
1542
+ turnNotes.push(msg);
1543
+ turnProgress.phase = '内存降级重试(已压缩上下文)';
1544
+ turnProgress.memoryRecovery = lastMemoryRecovery;
1545
+ steps = Math.max(0, steps - 1); // 重试不额外消耗一步,避免 maxSteps=1 时根本没机会重试
1546
+ continue;
1547
+ }
1443
1548
  // v0.6.15(§3.59 第 2 条):`engine shed its caches and stays up` / `allocator wall`
1444
1549
  // 说明引擎**活着**(自己丢缓存后继续服务)——不走这条"507 旧文案"(那里建议重启引擎,
1445
1550
  // 对活着但内存吃紧的引擎是错误处置);交给下面 describeUpstreamError 的独立分型。
@@ -1552,7 +1657,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1552
1657
  if (steps === stepLimit) {
1553
1658
  io.endTurn();
1554
1659
  if (res.text) {
1555
- const ap = await applyOutputConstraints(res.text, { messages, provider, activeModel, temperature, maxOutput, usage, signal: currentAc?.signal });
1660
+ const ap = await applyOutputConstraints(res.text, { messages, provider, activeModel, temperature, maxOutput: turnMaxOutput, usage, signal: currentAc?.signal });
1556
1661
  messages.push({ role: 'assistant', content: ap.text });
1557
1662
  // 步数已耗尽:这一条与下方 `break`(→ 兜底总结/截断收尾)必须是同一口径
1558
1663
  markOutcome('capped', { capHit: true });
@@ -1907,7 +2012,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1907
2012
  if (res.text) {
1908
2013
  // v0.5.0 A3 ③:输出前领域约束必须在**回填历史之前**应用——
1909
2014
  // 否则被拦下的违规措辞会留在会话里,下一轮又被当作既成事实喂回模型。
1910
- const ap = await applyOutputConstraints(res.text, { messages, provider, activeModel, temperature, maxOutput, usage, signal: currentAc?.signal });
2015
+ const ap = await applyOutputConstraints(res.text, { messages, provider, activeModel, temperature, maxOutput: turnMaxOutput, usage, signal: currentAc?.signal });
1911
2016
  finalText = ap.text;
1912
2017
  outputNote = ap.note;
1913
2018
  messages.push({ role: 'assistant', content: finalText });
@@ -2080,7 +2185,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2080
2185
  messages: wrapReq,
2081
2186
  tools: [],
2082
2187
  temperature,
2083
- maxTokens: Math.min(maxOutput, 2048),
2188
+ maxTokens: Math.min(turnMaxOutput, 2048),
2084
2189
  reasoningEffort: modelPreset(activeModel)?.supportsReasoning ? 'low' : undefined,
2085
2190
  signal: currentAc.signal,
2086
2191
  onDelta(/** @type {any} */ d) {
@@ -2094,7 +2199,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2094
2199
  }
2095
2200
  // v0.5.0 A3 ③:兜底总结同样过一遍输出约束
2096
2201
  const wrapApplied = wrapRes.text
2097
- ? await applyOutputConstraints(wrapRes.text, { messages, provider, activeModel, temperature, maxOutput, usage, signal: currentAc?.signal })
2202
+ ? await applyOutputConstraints(wrapRes.text, { messages, provider, activeModel, temperature, maxOutput: turnMaxOutput, usage, signal: currentAc?.signal })
2098
2203
  : { text: null, note: null };
2099
2204
  if (wrapApplied.text) messages.push({ role: 'assistant', content: wrapApplied.text });
2100
2205
  // capHit:本轮因步数上限被迫收尾(任务可能未真正完成)→ 供上层落检查点续跑
@@ -2213,6 +2318,38 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2213
2318
  }
2214
2319
  }
2215
2320
 
2321
+ /**
2322
+ * 回合包装层(v0.6.18 §3.74):把「本回合必须让用户看见」的提示统一追加到 done.note。
2323
+ *
2324
+ * 为什么需要包装:`runTurnInner` 有十几个 return 路径(工具收尾、cap、stalled、aborted…),
2325
+ * 逐条补 note 必漏。这里只做两件事:
2326
+ * ① 第一回合发请求前打印「按引擎 n_ctx clamp 预算」的提示(只打一次,避免每回合刷屏);
2327
+ * ② 回合结束后把 clamp 提示与内存降级重试的**压缩前后数值 / 重试次数 / 最终结果**
2328
+ * 追加到 `note`——WebUI 的 done.note、CLI 的收尾说明因此共用同一份口径。
2329
+ */
2330
+ async function runTurn(/** @type {any} */ messages) {
2331
+ turnNotes = [];
2332
+ if (budgetClampNotice && !clampNoticeShown) {
2333
+ clampNoticeShown = true;
2334
+ try { io.print(style(budgetClampNotice, C.yellow)); } catch {}
2335
+ turnNotes.push(budgetClampNotice);
2336
+ }
2337
+ const r = await runTurnInner(messages);
2338
+ const notes = turnNotes.filter(Boolean);
2339
+ if (lastMemoryRecovery && r) {
2340
+ const rec = lastMemoryRecovery;
2341
+ const final = r.aborted ? '重试后被中断' : r.stalled ? '重试后无进展中止' : r.text ? '重试后成功' : '重试后仍无正文';
2342
+ notes.push(
2343
+ `↩ 内存降级重试(${Number(rec.attempts) || 1} 次):上下文预算 ${fmtTok(rec.fromBudget)} → ${fmtTok(rec.toBudget)}、` +
2344
+ `输出上限 ${fmtTok(rec.fromMaxOutput)} → ${fmtTok(rec.toMaxOutput)};${final}。`
2345
+ );
2346
+ }
2347
+ if (notes.length && r && typeof r === 'object') {
2348
+ r.note = [r.note, ...notes].filter(Boolean).join('\n');
2349
+ }
2350
+ return r;
2351
+ }
2352
+
2216
2353
  return {
2217
2354
  modelName,
2218
2355
  budget,
@@ -866,6 +866,45 @@ export function engineContextNotice({ engineCtx = null, configuredWindow = null,
866
866
  return `引擎自述上下文 ${eng} vs 配置 ${conf ?? '(未声明)'} —— 配置未超出引擎能力${where}`;
867
867
  }
868
868
 
869
+ // ---------------------------------------------------------------------------
870
+ // v0.6.18 §3.74:**发请求前按引擎自述 n_ctx clamp 预算**
871
+ //
872
+ // 只"提示配置超出引擎能力"还不够:真机(v0.6.17,8081 qwen)配置 contextBudget=32768、
873
+ // 引擎自述 n_ctx 也很小,请求照样发出去,然后整回合被 `insufficient memory` 拒绝。
874
+ // 既然预检已经读到了 n_ctx,就在**发请求之前**把 prompt 预算压到 n_ctx×0.8 以下——
875
+ // 明知会炸还发,等于把一次必然失败的 prefill 再烧一遍。
876
+ //
877
+ // 为什么留 20%:引擎的 n_ctx 是 prompt+输出/KV cache 的总窗口,输出上限、模板开销与
878
+ // 并发请求都要占同一份窗口;贴着 n_ctx 发等于没有余量。0.8 与 PREFILL_WARN_RATIO 同口径。
879
+ // ---------------------------------------------------------------------------
880
+
881
+ /** 引擎自述 n_ctx 的预算占比(超过即 clamp)。 */
882
+ export const ENGINE_BUDGET_RATIO = 0.8;
883
+
884
+ /**
885
+ * 按引擎自述上下文 clamp prompt 预算(纯函数,便于断言/变异)。
886
+ *
887
+ * @param {{ contextBudget?: number|null, configuredBudget?: number|null, engineCtx?: number|null, ratio?: number }} [input]
888
+ * @returns {{ clamped: boolean, budget: number|null, from: number|null, to: number|null,
889
+ * engineCtx: number|null, ceiling: number|null, ratio: number, notice: string }}
890
+ */
891
+ export function clampContextBudgetToEngine({ contextBudget = null, configuredBudget = null, engineCtx = null, ratio = ENGINE_BUDGET_RATIO } = {}) {
892
+ const raw = Number(contextBudget) > 0 ? Math.floor(Number(contextBudget)) : null;
893
+ const confRaw = Number(configuredBudget) > 0 ? Math.floor(Number(configuredBudget)) : raw;
894
+ const eng = Number(engineCtx) > 0 ? Math.floor(Number(engineCtx)) : null;
895
+ const r = Number(ratio) > 0 && Number(ratio) < 1 ? Number(ratio) : ENGINE_BUDGET_RATIO;
896
+ const base = { clamped: false, budget: raw, from: raw, to: raw, engineCtx: eng, ceiling: null, ratio: r, notice: '' };
897
+ if (raw == null || eng == null) return base; // 没配置/没读到 n_ctx → 不猜、不 clamp
898
+ const ceiling = Math.max(1024, Math.floor(eng * r));
899
+ if (raw <= ceiling) return { ...base, ceiling };
900
+ const to = ceiling;
901
+ const notice =
902
+ `⚠ 引擎自述上下文 ${eng}:config.contextBudget ${confRaw} 超过其 ${Math.round(r * 100)}%(${ceiling}),` +
903
+ `发请求前已自动 clamp 上下文预算 ${raw} → ${ceiling}——否则长上下文会在 prefill 阶段被引擎直接拒绝` +
904
+ `(insufficient memory)。如需更大上下文,请在引擎侧调大 --ctx-size,而不是只改 config.contextBudget。`;
905
+ return { clamped: true, budget: to, from: raw, to, engineCtx: eng, ceiling, ratio: r, notice };
906
+ }
907
+
869
908
  export function modelCacheFile() {
870
909
  return path.join(mingdaoHome(), 'model-cache.json');
871
910
  }
@@ -11,7 +11,9 @@
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { pathToFileURL } from 'node:url';
14
- import { chat as openaiChat } from './openai-compatible.js';
14
+ import { chat as openaiChat, isMemoryPressureError } from './openai-compatible.js';
15
+ // 供 agent/model-discovery 与回归套件复用同一条「内存压力」判据(单一来源,避免各层各写一份正则)。
16
+ export { isMemoryPressureError };
15
17
  import { modelPreset, providerPreset } from '../models.js';
16
18
  import { mingdaoHome } from '../config.js';
17
19
  import { resolveApiKey, getStoredKey } from '../credentials.js';
@@ -421,6 +423,11 @@ const sleep = (/** @type {any} */ ms, /** @type {any} */ signal) =>
421
423
  });
422
424
 
423
425
  function isTransient(/** @type {any} */ err) {
426
+ // v0.6.18 §3.74:内存/显存不足**不是瞬态网络抖动**——原样重发同一份大 prompt 只会再撞一次
427
+ // 内存墙(真机 8081:父回合 + 子代理并发 prefill)。这类错误交给 agent 做「压缩上下文 +
428
+ // 输出减半 + 强制串行」的一次性恢复;这里必须**不重试**,否则同回合会白烧 2 次请求
429
+ // (且 500+insufficient memory 会被 `status >= 500` 误判成瞬态)。
430
+ if (isMemoryPressureError(err)) return false;
424
431
  const status = err?.status;
425
432
  if (status === 429 || (status >= 500 && status <= 504)) return true;
426
433
  // 注意:不含 abort —— 用户 Ctrl+C 主动中断不应触发重试
@@ -511,7 +518,9 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
511
518
  // 必须在**首 token 计时之前**排队:排队是内核自己的调度,不是"引擎迟迟不出首帧"。
512
519
  // 云端端点由 acquireLocalSlot 内部直接放行(不排队、不记日志、不加延迟)。
513
520
  releaseLocal = await acquireLocalSlot(pc.baseUrl, {
514
- cfg,
521
+ // v0.6.18 §3.74:内存降级重试时 agent 传 localConcurrency=1,**临时把该端点压回单并发**
522
+ // (即便用户把 config.localConcurrency 配成 0/2)——重试期间不允许父回合/子代理再同时 prefill。
523
+ cfg: opts.localConcurrency != null ? { ...cfg, localConcurrency: opts.localConcurrency } : cfg,
515
524
  signal: ac.signal,
516
525
  onWait: opts.onQueueWait,
517
526
  endpoint: pc.name || pc.baseUrl,
@@ -17,9 +17,36 @@ function extractStreamError(/** @type {any} */ json) {
17
17
  /** @type {ApiError} */
18
18
  const e = new Error(`[流式响应错误${code ? ' ' + code : ''}] ${msg}`);
19
19
  if (code) e.status = code;
20
+ if (isMemoryPressureError(e)) /** @type {any} */ (e).memoryPressure = true;
20
21
  return e;
21
22
  }
22
23
 
24
+ // 本地引擎「内存/显存不足」的判据(纯函数,v0.6.18 §3.74):
25
+ // · HTTP 507(Insufficient Storage,llama.cpp / vLLM 的内存拒绝常用);
26
+ // · 真机原文 `insufficient memory … aborted before the allocator wall` /
27
+ // `The engine shed its caches and stays up` / `memory_refusal`;
28
+ // · 中文本地引擎常见措辞(内存不足 / 内存拒绝 / 显存不足);
29
+ // · 通用 OOM 措辞(CUDA out of memory / OOM)。
30
+ // **只认"内存类"**:400/401/429 与普通 5xx 一律不命中——否则会把重试/压缩用在无意义的场景上。
31
+ // 注意:这是**协议层**判据(有没有可能是内存问题);「能不能压缩重试」还要叠加
32
+ // `isLocalBaseUrl` —— 云 API 的 507 不压缩重试(见 agent.js 的 describeUpstreamError)。
33
+ const MEMORY_PRESSURE_RE =
34
+ /insufficient memory|memory_refusal|allocator wall|engine shed its caches and stays up|out of memory|\boom\b|cuda.*memory|gpu memory|显存(?:不足|不够)|内存(?:不足|拒绝|不够)/i;
35
+
36
+ /**
37
+ * 这次错误是否属于「本地引擎内存/显存不足」(纯函数)。
38
+ * @param {any} err
39
+ * @returns {boolean}
40
+ */
41
+ export function isMemoryPressureError(/** @type {any} */ err) {
42
+ const status = Number(err?.status);
43
+ if (status === 507) return true;
44
+ const text = String(err?.message ?? err ?? '');
45
+ if (!text) return false;
46
+ if (MEMORY_PRESSURE_RE.test(text)) return true;
47
+ return Boolean(err?.memoryPressure);
48
+ }
49
+
23
50
  export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, onActivity, includeUsage = true, responseFormat, reasoningEffort }) {
24
51
  const url = String(baseUrl).replace(/\/+$/, '') + '/chat/completions';
25
52
  const payload = /** @type {Record<string, any>} */ ({ model, messages });
@@ -76,6 +103,7 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
76
103
  const e = new Error(`[${model}] API 错误 ${res.status}: ${detail}${hint}`);
77
104
  e.status = res.status;
78
105
  e.headers = res.headers; // 重试退避读取 Retry-After 用
106
+ if (isMemoryPressureError(e)) /** @type {any} */ (e).memoryPressure = true;
79
107
  throw e;
80
108
  }
81
109