mingdao-harness 0.6.25 → 0.6.26

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/docs/CONFIG.md CHANGED
@@ -772,7 +772,7 @@ request failed.` 同一错误此前在 60091 的 35B 上也出现过 → **不
772
772
  3. 临时把 `localConcurrency` 保持 `1`,并减少子代理数量(读只读子代理在本地模型下已不并行);
773
773
  4. **不要**因为 `engine shed its caches and stays up` 去重启引擎——它没有崩;重启只会重新加载模型。
774
774
 
775
- #### 内存/显存不足时的自动降级重试(v0.6.18 §3.74)
775
+ #### 内存/显存不足时的多级压缩 + 端点级学习(v0.6.26 §3.91,v2)
776
776
 
777
777
  即使有串行化,本地引擎仍可能因**子代理并发 / 长解码 / 其他进程占显存**在 prefill 时拒绝请求:
778
778
 
@@ -782,21 +782,56 @@ insufficient memory: the request exceeded available GPU memory
782
782
  The engine shed its caches and stays up; this request failed.
783
783
  ```
784
784
 
785
- 真机(v0.6.17,8081 qwen)当时上下文只有 ≈2,773 tokens、预算 32,768——**整回合却直接失败**。
786
- 现在对**本地端点**(`isLocalBaseUrl` 为真,或 `customModels.<名>.local: true`)改为**一次**有界降级重试:
785
+ 真机(10-18 / 10-19 / 10-26 三次同形态)证明 v0.6.18 的「一级压缩 + 一次重试」不够:
786
+ 32,768 → 19,660 之后仍被拒绝,整回合直接失败;更关键的是**内核不记住**——下一回合又从
787
+ 32,768 撞同一堵墙。现在对**本地端点**(`isLocalBaseUrl` 为真,或 `customModels.<名>.local: true`)
788
+ 改为两级压缩 + 端点级学习:
787
789
 
788
790
  | 动作 | 数值 |
789
791
  | --- | --- |
790
- | 压缩上下文预算 | 本回合 `contextBudget × 0.6`,**下限 4096**(例:32,768 → ~19,660) |
791
- | 降低输出上限 | 本回合 `maxOutputTokens × 0.5`(例:8,192 → 4,096) |
792
- | 串行化加强 | 该重试传 `localConcurrency: 1`(复用既有的 `acquireLocalSlot`),即便你配了 0/2 |
793
- | 重试次数 | **最多一次**;第二次仍失败即如实报错,绝不无限重试 |
794
-
795
- - **可见性**:界面与日志出现
796
- `♻ 因本地引擎内存不足,已压缩上下文(32,768 → ~19,660)…并重试一次`;
797
- 回合成功时 `done.note` 追加压缩前后数值、重试次数与最终结果(`↩ 内存降级重试(1 次)…重试后成功`)。
798
- - **第二次仍失败**:回合失败,错误文案带压缩前后数值与具体建议——把 `config.contextBudget` 调到多少、
799
- 关闭其他占用显存的模型进程/并行会话、引擎侧调大 `--ctx-size` 或使用 q8 KV 量化。
792
+ | 第 1 级压缩 | 预算 `× 0.6`(例:32,768 → 19,660)、输出上限 `× 0.5`(8,192 → 4,096) |
793
+ | 第 2 级压缩 | 第 1 级仍失败 → 预算 `× 0.35`(32,768 → 11,468)、输出上限 `× 0.25`(8,192 → 2,048) |
794
+ | 下限 | 预算 **4,096**、输出上限 **512**(再低连系统提示 + 一轮工具结果都放不下) |
795
+ | 串行化加强 | 每次重试传 `localConcurrency: 1`(复用既有的 `acquireLocalSlot`),即便你配了 0/2 |
796
+ | 重试次数 | **硬性上限 2 次**;两级都失败即如实报错,绝不无限重试 |
797
+
798
+ > 两级比例都相对**本回合起手值**计算,不是逐级连乘——这样日志/文档里的数字一一对应,
799
+ > 不会被 0.6×0.35 悄悄压到连系统提示都放不下。
800
+
801
+ - **每次重试都可见**:界面与日志各一行
802
+ `♻ 内存降级重试 第 1/2 次:…已压缩上下文(32,768 → ~19,660)、输出上限 8,192 → 4,096…`,
803
+ 第 2 级再来一行;回合成功时 `done.note` 追加两级链路、次数与最终结果
804
+ (`↩ 内存降级重试(2 次,最多 2 次,已压缩上下文):预算 32,768 → 19,660 → 11,468…`)。
805
+ - **两级都失败 → 端点级学习**:把**最后尝试的预算**写入 `<mingdao-home>/endpoint-limits.json`
806
+ (键 = 归一化 `baseUrl`):
807
+ ```json
808
+ {
809
+ "version": 1,
810
+ "endpoints": {
811
+ "http://127.0.0.1:8081/v1": {
812
+ "safeContextBudget": 11468,
813
+ "learnedAt": "2026-10-26T01:00:22.000Z",
814
+ "source": "memory-failure",
815
+ "attempts": 2,
816
+ "maxOutputTokens": 2048
817
+ }
818
+ }
819
+ }
820
+ ```
821
+ - **下一回合起手就用学习值**:该 `baseUrl` 的请求直接以 `safeContextBudget` 为预算
822
+ (若配置预算本来就比它小,则用户配置优先),并在 `chat 预告` 里说明出处:
823
+ `📉 该端点曾因内存不足降级,本轮预算 11,468(来自 2026-10-26 的失败学习;端点安全预算 11,468)`。
824
+ 同一进程内的后续回合会**重新读取**该文件,不必重启。
825
+ - **恢复策略(上调)**:连续 **3 次**「干净成功」(本回合无内存压缩、无中断/无进展/截断)后,
826
+ 学习值 `× 1.25`(+25%),上限 = 用户配置的预算;上调会写日志
827
+ `📈 该端点已连续 3 次干净成功:内存安全预算上调 8,192 → 10,240(+25%)`。
828
+ 上调后若再撞内存墙,两级压缩仍失败会把它重新学下来——降级/恢复都基于真实结果。
829
+ - **学习文件坏/不可写不阻断会话**:读失败(损坏/权限)→ 打一条 warn,按"无学习值"继续,
830
+ 且**不覆盖**损坏文件(留待人工修复);写失败 → warn + 降级,回合仍按正常内存失败文案收口。
831
+ - **失败建议与学习值一致**:错误文案不再给一个临时数字,而是
832
+ `已把该端点内存安全预算记为 11,468(写入 …/endpoint-limits.json),下轮起手自动使用——config.contextBudget 也可手动调到 11,468。`
833
+ - **子代理继承**:父回合压缩后派出的子代理,请求直接使用父回合**当前生效**的压缩预算/输出上限
834
+ (不再是压缩前的原预算);子代理自己也会独立执行同一套两级压缩(两条路都在)。
800
835
  - **云端端点不重试**:云 API 的 507/容量问题不是本地上下文能改的,压缩重试只会多烧一次请求;
801
836
  400/401/429 等非内存错误同样不触发该恢复。
802
837
  - **判据单一来源**:`isMemoryPressureError`(协议层:507 / `insufficient memory` / `memory_refusal` /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.25",
3
+ "version": "0.6.26",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -20,7 +20,18 @@ import { redactSecrets } from './redact.js';
20
20
  import { checkCostGuard, costGuardConfig, todayCost, preflightBlockMessage, roundGuardAction } from './cost-guard.js';
21
21
  import { recordCacheStats, packDailyCost } from './cachestats.js';
22
22
  import { estimateCost, cacheSplit, isPeakHour } from './pricing.js';
23
- import { resolveProviderConfig, createProvider, localGateSnapshot, localConcurrencyLimit, isMemoryPressureError } from './providers/index.js';
23
+ import {
24
+ resolveProviderConfig,
25
+ createProvider,
26
+ localGateSnapshot,
27
+ localConcurrencyLimit,
28
+ isMemoryPressureError,
29
+ // v0.6.26 §3.91:端点级内存安全预算的读/学/上调(文件读失败一律降级为"无学习值")
30
+ endpointLimitFor,
31
+ learnEndpointMemoryFailure,
32
+ noteEndpointSuccess,
33
+ ENDPOINT_UPGRADE_SUCCESS_STREAK,
34
+ } from './providers/index.js';
24
35
  import { compileConstraints, checkPreTool, checkPostTool, checkOutput, blockedOutputText } from './constraints.js';
25
36
  import { createLedger, newRunId } from './ledger.js';
26
37
  import { registerEgressSink } from './net-guard.js';
@@ -79,12 +90,63 @@ export const DEFAULT_NO_PROGRESS_TIMEOUT_MS = 3600000;
79
90
  * 形态下多等 15s(相对默认 60 分钟是 0.4%),换"子代理自己的结论能回传父回合"。 */
80
91
  export const SUBAGENT_WAIT_GRACE_MS = 15000;
81
92
 
82
- /** 本地引擎内存不足时允许的**降级重试次数**(v0.6.18 §3.74)。
93
+ /** 本地引擎内存不足时的**多级压缩**策略(v0.6.26 §3.91)。
83
94
  *
84
- * 只允许一次:内存墙不是网络抖动——同一份大 prompt 重发只会再撞一次。第一次失败后
85
- * agent 会压缩上下文 + 输出减半 + 强制串行;第二次仍失败就如实报错,绝不无限重试。
86
- * 抽成常量是为了让"最多一次"可被断言/变异钉住(不是散在 if 里的魔法值)。 */
87
- export const MAX_MEMORY_RETRIES = 1;
95
+ * 真机三次同形态失败(10-18 / 10-19 / 10-26)证明"只压一级、只重试一次"不够:
96
+ * 32,768 → 19,660 之后仍被引擎拒绝,整回合直接失败,而更狠的一级(0.35)没人试过。
97
+ * 两级都相对**本回合起手预算**计算(不是逐级连乘),这样日志/文档里的数值一一对应,
98
+ * 不会被"0.6×0.35"这种连乘悄悄压到系统提示都放不下:
99
+ * 第 1 级:预算 ×0.6(32,768 → 19,660)、输出 ×0.5(8,192 → 4,096);
100
+ * 第 2 级:预算 ×0.35(32,768 → 11,468)、输出 ×0.25(8,192 → 2,048)。
101
+ * 下限:预算 4,096(再低连系统提示 + 一轮工具结果都放不下)、输出 512。
102
+ * 抽成常量是为了让"最多 2 次 / 每级参数"可被断言与变异钉住。 */
103
+ export const MEMORY_RECOVERY_LEVELS = Object.freeze([
104
+ Object.freeze({ budgetRatio: 0.6, maxOutputRatio: 0.5, minBudget: 4096, minMaxOutput: 512 }),
105
+ Object.freeze({ budgetRatio: 0.35, maxOutputRatio: 0.25, minBudget: 4096, minMaxOutput: 512 }),
106
+ ]);
107
+
108
+ /** 内存降级重试的**硬性上限** = 压缩级数(2)。绝不允许无限重试。 */
109
+ export const MAX_MEMORY_RETRIES = MEMORY_RECOVERY_LEVELS.length;
110
+
111
+ /**
112
+ * 第 retry 级压缩后的预算/输出上限(纯函数,便于断言/变异)。
113
+ * @param {{ budget: number, maxOutput: number, retry: number }} input
114
+ * @returns {{ level: number, budget: number, maxOutput: number }}
115
+ */
116
+ export function memoryRecoveryTarget({ budget, maxOutput, retry }) {
117
+ const want = Math.max(1, Math.floor(Number(retry) || 1));
118
+ const idx = Math.min(MEMORY_RECOVERY_LEVELS.length, want) - 1;
119
+ const level = MEMORY_RECOVERY_LEVELS[idx];
120
+ return {
121
+ level: idx + 1,
122
+ budget: Math.max(level.minBudget, Math.floor((Number(budget) || 0) * level.budgetRatio)),
123
+ maxOutput: Math.max(level.minMaxOutput, Math.floor((Number(maxOutput) || 0) * level.maxOutputRatio)),
124
+ };
125
+ }
126
+
127
+ /** 学习值日期:ISO → YYYY-MM-DD(给"来自 YYYY-MM-DD 的失败学习"用,时区固定 UTC,避免跨时区漂一天)。 */
128
+ function fmtLearnedDate(/** @type {any} */ value) {
129
+ const d = new Date(value);
130
+ return Number.isNaN(d.getTime()) ? '未知日期' : d.toISOString().slice(0, 10);
131
+ }
132
+
133
+ /**
134
+ * 端点学习值的「chat 预告」文案(纯函数):必须同时说清**本轮预算是多少**与**这个数字从哪来**。
135
+ * 只报"预算已降级"而不给出处,用户无法判断该不该相信它(也无法判断要不要手动调回去)。
136
+ * @param {{ budget?: number, record?: any }} input
137
+ * @returns {string}
138
+ */
139
+ export function endpointLearnedNotice({ budget, record } = {}) {
140
+ const safe = Math.floor(Number(record?.safeContextBudget) || 0);
141
+ const used = Math.floor(Number(budget) || 0);
142
+ if (!(safe > 0) || !(used > 0)) return '';
143
+ const how = record?.source === 'success-upgrade' ? '成功上调' : '失败学习';
144
+ return (
145
+ `📉 该端点曾因内存不足降级,本轮预算 ${fmtTok(used)}` +
146
+ `(来自 ${fmtLearnedDate(record?.learnedAt)} 的${how};端点安全预算 ${fmtTok(safe)})`
147
+ );
148
+ }
149
+
88
150
 
89
151
  /** 解析无进展阈值:非正/非有限一律回落默认(fail-safe,防"配置写 0 就静默失去看门狗")。 */
90
152
  export function resolveNoProgressTimeoutMs(/** @type {any} */ cfg) {
@@ -158,12 +220,16 @@ const fmtTok = (/** @type {any} */ n) => Number(n || 0).toLocaleString('en-US');
158
220
  * v0.6.15(§3.59 第 3 条):本地端点**已有在途请求**(父回合/子代理/预检之一)时加一句强警告——
159
221
  * 真机 8081 的内存拒绝就是"父子回合同时 prefill"撞出来的;串行化已经兜住资源,但用户仍应
160
222
  * 知道"并发会加剧内存压力、应减少子代理"。
223
+ *
224
+ * v0.6.26 §3.91:`learned`(端点学习值)存在时,预告里必须写明"本轮预算 N 来自哪天的失败学习"——
225
+ * 用户看到预算比自己配置的小,必须能一眼知道是内核学的、不是配置被改了。
161
226
  * @param {{ promptTokens: number, contextWindow?: number, isLocal?: boolean,
162
227
  * firstTokenMs?: number, budget?: number, probe?: any,
163
- * localBusy?: boolean, localActive?: number, localQueued?: number, localConcurrency?: number }} input
228
+ * localBusy?: boolean, localActive?: number, localQueued?: number, localConcurrency?: number,
229
+ * learned?: any }} input
164
230
  * @returns {{ text: string, warn: boolean, promptTokens: number, ratio: number, estimateMs: number|null }}
165
231
  */
166
- export function prefillForecast({ promptTokens, contextWindow, isLocal, firstTokenMs, budget, probe = null, localBusy = false, localActive = 0, localQueued = 0, localConcurrency = 1 }) {
232
+ export function prefillForecast({ promptTokens, contextWindow, isLocal, firstTokenMs, budget, probe = null, localBusy = false, localActive = 0, localQueued = 0, localConcurrency = 1, learned = null }) {
167
233
  const p = Math.max(0, Math.round(Number(promptTokens) || 0));
168
234
  const win = Number(contextWindow) > 0 ? Number(contextWindow) : 0;
169
235
  const ratio = win > 0 ? p / win : 0;
@@ -217,7 +283,9 @@ export function prefillForecast({ promptTokens, contextWindow, isLocal, firstTok
217
283
  // v0.6.13(A 追加):把**端点预检**结果并进这条预告——"这个端点行不行"与"这次要 prefill 多少"
218
284
  // 必须一起出现在发送前,而不是等出事后翻日志(引擎实际加载的模型名与配置名不一致也在这里说)。
219
285
  const probeLine = probe && probe.note ? `\n 端点预检:${probe.note}` : '';
220
- return { text: `${head}\n ${timing}${probeLine}${advice}${concurrencyNote}`, warn: warn || Boolean(concurrencyNote), promptTokens: p, ratio, estimateMs: est ? est.ms : null };
286
+ // v0.6.26 §3.91:端点学习值必须出现在**发送前的预告**里(预算为什么比配置小、这个数字从哪来)。
287
+ const learnedNote = endpointLearnedNotice({ budget, record: learned });
288
+ return { text: `${head}\n ${timing}${probeLine}${advice}${concurrencyNote}${learnedNote ? `\n ${learnedNote}` : ''}`, warn: warn || Boolean(concurrencyNote), promptTokens: p, ratio, estimateMs: est ? est.ms : null };
221
289
  }
222
290
 
223
291
  /**
@@ -294,8 +362,12 @@ export function zeroToolCallNotice({ modelRounds, steps = 0, taskLike = false })
294
362
  * v0.6.18 §3.74:额外返回 `recoverableByShrinking`——**只有本地端点**的内存/显存不足
295
363
  * 才是"压缩上下文 + 降输出 + 串行重试一次"能救的;云端 API 的 507/内存报错不在此列
296
364
  * (云端的容量/配额不是本地上下文能改的,对云 API 做压缩重试只会多烧一次请求)。
365
+ *
366
+ * v0.6.26 §3.91:`learned`(该端点的学习值)存在时,建议里必须给出**与学习值一致**的数字
367
+ * ("已把该端点预算记为 N,下轮自动使用"),而不是再抛一个"调到 N 左右"的临时建议——
368
+ * 后者正是真机上"日志给了 13,762、下一回合仍按 32,768 撞墙"的原因。
297
369
  * @param {any} err
298
- * @param {{ isLocal?: boolean }} [opts]
370
+ * @param {{ isLocal?: boolean, learned?: any }} [opts]
299
371
  * @returns {{ message: string, hint: string|null, status: number|null, recoverableByShrinking: boolean }}
300
372
  */
301
373
  export function describeUpstreamError(/** @type {any} */ err, opts = {}) {
@@ -304,6 +376,11 @@ export function describeUpstreamError(/** @type {any} */ err, opts = {}) {
304
376
  const t = raw.toLowerCase();
305
377
  const memoryPressure = isMemoryPressureError(err);
306
378
  const recoverableByShrinking = Boolean(memoryPressure && opts?.isLocal === true);
379
+ const learnedSafe = Math.floor(Number(opts?.learned?.safeContextBudget) || 0);
380
+ const learnedHint =
381
+ learnedSafe > 0
382
+ ? `该端点的内存安全预算已记为 ${fmtTok(learnedSafe)}(来自 ${fmtLearnedDate(opts?.learned?.learnedAt)} 的失败学习),下轮起手自动使用。`
383
+ : '';
307
384
  /** @type {string|null} */
308
385
  let hint = null;
309
386
  if (/jinja|template|no user query|parser for this template/.test(t)) {
@@ -318,10 +395,12 @@ export function describeUpstreamError(/** @type {any} */ err, opts = {}) {
318
395
  hint =
319
396
  '本地引擎**内存/显存不足**,但它**未崩溃**(原文:engine shed its caches and stays up——引擎自己丢弃缓存后仍在运行)。' +
320
397
  '**不要重启引擎**(重启不会增加显存,反而要重新加载模型)。处置:压缩上下文(减小 config.contextBudget 或 /compact)、' +
321
- '减少并发子任务/并行会话(同一本地端点已按 config.localConcurrency 串行化),然后可直接重试——引擎通常会自行恢复。';
398
+ '减少并发子任务/并行会话(同一本地端点已按 config.localConcurrency 串行化),然后可直接重试——引擎通常会自行恢复。' +
399
+ learnedHint;
322
400
  } else if (/memory_refusal|insufficient memory|gib|内存不足|内存拒绝/.test(t)) {
323
401
  hint =
324
- '本地引擎**内存/显存不足**(在 prefill 前就拒绝了)。处置:压缩上下文(减小 config.contextBudget 或 /compact)、减少并发子任务,或重启模型服务释放内存后重试。';
402
+ '本地引擎**内存/显存不足**(在 prefill 前就拒绝了)。处置:压缩上下文(减小 config.contextBudget 或 /compact)、减少并发子任务,或重启模型服务释放内存后重试。' +
403
+ learnedHint;
325
404
  } else if (/首 token 等待超限|流式响应空闲超限|请求总时长超限/.test(raw)) {
326
405
  hint =
327
406
  '这是**内核自己的超时**(不是上游报错):端点连上了但迟迟不产出。本地大模型长上下文 prefill 很慢——可调大 config.timeout.firstTokenMs/streamIdleMs/totalMs,' +
@@ -438,6 +517,15 @@ export function startsInReadOnlyPhase(cfg, lastUserText, packActive) {
438
517
  return !hasWriteIntent(t) && looksLikeQuestion(t);
439
518
  }
440
519
 
520
+ /** 该模型请求实际发往的 baseUrl(端点级学习/并发闸门共用同一口径;取不到时返回 '')。 */
521
+ function resolveEndpointBaseUrl(/** @type {any} */ cfg, /** @type {any} */ modelName) {
522
+ try {
523
+ return String(resolveProviderConfig(cfg, modelName)?.baseUrl || cfg?.baseUrl || '');
524
+ } catch {
525
+ return String(cfg?.baseUrl || '');
526
+ }
527
+ }
528
+
441
529
  /**
442
530
  * 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
443
531
  * @param {{ provider: any, permission: any, io: any, modelName: any, workingDir: any,
@@ -450,6 +538,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
450
538
  // v0.3.2 模型自适应:预算按模型上下文窗口推导(留输出余量 + 75% 舒适区),
451
539
  // 自定义/本地小模型不再套 128000 默认撑爆窗口;prompt 永不逼近窗口边缘(prefill 不爆炸)。
452
540
  const caps = resolveModelCaps(cfg, modelName);
541
+ // v0.6.26 §3.91:子代理实例(spawnTask 传入 parentProgress / 子任务标记)。子代理的成功**不**计入
542
+ // 端点"连续干净成功"——一个父回合里可能跑多个子代理,否则一次回合就能把 +25% 的上调门槛凑满。
543
+ const isSubagent = Boolean(cfg?.parentProgress || cfg?.subagentLabel);
453
544
  // safeBudget 恒用:即使用户显式 contextBudget 也套「窗口−输出−余量」上限与 75% 舒适区,
454
545
  // 防显式值撑爆窗口(本地模型窗口可能只有 32k/131k,用户却留了默认 128000)。
455
546
  const safeB = safeBudget(cfg, caps);
@@ -461,7 +552,20 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
461
552
  configuredBudget: Number(cfg?.contextBudget) > 0 ? Number(cfg.contextBudget) : null,
462
553
  engineCtx: cfg?.endpointProbe?.engineCtx ?? null,
463
554
  });
464
- const budget = budgetClamp.budget ?? safeB;
555
+ const configuredBudget = budgetClamp.budget ?? safeB;
556
+ // v0.6.26 §3.91(端点级学习):该 baseUrl 若曾"多级压缩后仍失败",**本回合起手就用学到的
557
+ // safeContextBudget**,而不是每回合从同一个注定失败的预算重撞一次(真机三次同形态失败)。
558
+ // 读文件失败只打一条 warn 并退化为"无学习值",绝不影响会话(见 readEndpointLimits)。
559
+ const endpointBaseUrl = resolveEndpointBaseUrl(cfg, modelName);
560
+ const learnedLookup = endpointLimitFor(endpointBaseUrl);
561
+ if (learnedLookup.error) {
562
+ try { if (io?.print) io.print(style(`⚠ ${learnedLookup.error}`, C.yellow)); } catch {}
563
+ }
564
+ const learnedBudget = Math.floor(Number(learnedLookup.record?.safeContextBudget) || 0);
565
+ // 只在学习值**确实小于**配置预算时接管;配置本来就比学习值小时,用户的选择优先。
566
+ const learnedApplied = Boolean(learnedBudget > 0 && learnedBudget < configuredBudget);
567
+ const budget = learnedApplied ? learnedBudget : configuredBudget;
568
+ const learnedStart = learnedApplied ? { ...learnedLookup.record, appliedBudget: budget } : null;
465
569
  const budgetClampNotice = budgetClamp.clamped ? budgetClamp.notice : '';
466
570
  // maxOutput 也按窗口封顶:显式配超大 maxOutputTokens 时,prompt(预算)+output 仍不得越过窗口
467
571
  // (预算已按 caps.maxOutputTokens 留余量,但显式值可能更大——此处兜底,防服务端截断/拒绝)
@@ -523,6 +627,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
523
627
  // spawnTask 用它把父回合的"无进展截止时刻"传给子代理:子代理树必须一起收口,
524
628
  // 否则 24 个并行子代理各自跑满自己的看门狗,父回合看起来就是"挂了几小时"。
525
629
  let currentTurnProgress = /** @type {any} */ (null);
630
+ // v0.6.26 §3.91(子代理继承):本回合**实际生效**的预算/输出上限(内存压缩后立刻更新)。
631
+ // spawnTask 必须读它,否则父回合已压到 19,660、子代理仍按 32,768 发——真机那次「子代理
632
+ // 已跑 31 轮 / 50 次工具调用、29 分钟白跑」正是父子预算不一致的直接后果。
633
+ let currentTurnBudget = /** @type {number|null} */ (null);
634
+ let currentTurnMaxOutput = /** @type {number|null} */ (null);
635
+ // 本回合的端点学习值(runTurn 内按回合重读;runTurnInner 的发送前预告据此说明预算出处)。
636
+ let turnLearned = /** @type {any} */ (null);
637
+ // 本回合最后一次请求实际发往的端点(费用护栏降级可能换模型;成功学习要记在真正的端点上)。
638
+ let currentRequestBaseUrl = '';
526
639
  // 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
527
640
  const undo = undoStore || { backups: new Map() };
528
641
  const stepLimit = maxSteps || MAX_STEPS;
@@ -589,13 +702,17 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
589
702
  workingDir,
590
703
  cfg: {
591
704
  ...cfg,
592
- contextBudget: Math.min(budget, 64000),
705
+ // v0.6.26 §3.91(子代理继承):优先用**父回合当前生效**的预算/输出上限——
706
+ // 父回合因内存不足压到 19,660/4,096 后,子代理不得再按 32,768/8,192 发(那会再次撞墙)。
707
+ // 子代理自己也会走同一套多级压缩(createAgent 内部逻辑相同),两条路都保留。
708
+ contextBudget: Math.min(currentTurnBudget ?? budget, 64000),
709
+ maxOutputTokens: currentTurnMaxOutput ?? maxOutput,
593
710
  ...(currentTurnProgress
594
711
  ? { turnProgressDeadlineAt: currentTurnProgress.lastProgressAt + currentTurnProgress.noProgressMs }
595
712
  : {}),
596
- // 下面是 v0.6.13(问题 3)补的两项。**上面三行一个字都不动**:那个 spread 是 §139 与
597
- // batch28 ⑥ 共同钉住的形状(改缩进/换写法会让既有变异变成"变异点未找到"——本轮实际踩到过),
598
- // 而它现在的语义只当"spawn 时刻的快照兜底",真正的判据是 parentProgress(活引用)。
713
+ // 下面是 v0.6.13(问题 3)补的两项。**上面 `turnProgressDeadlineAt` 那个 spread 一个字都不动**:
714
+ // 它是 §139 与 batch28 ⑥ 共同钉住的形状(改缩进/换写法会让既有变异变成"变异点未找到"——
715
+ // 本轮实际踩到过),而它现在的语义只当"spawn 时刻的快照兜底",真正的判据是 parentProgress(活引用)。
599
716
  ...(currentTurnProgress ? { parentProgress: currentTurnProgress } : {}),
600
717
  ...(description ? { subagentLabel: description } : {}),
601
718
  onSubagentProgress: (/** @type {any} */ info) => {
@@ -779,14 +896,20 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
779
896
  };
780
897
  }
781
898
 
782
- async function runTurnInner(/** @type {any} */ messages) {
899
+ async function runTurnInner(/** @type {any} */ messages, /** @type {number} */ startBudget = budget) {
783
900
  let steps = 0;
784
901
  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)。 */
902
+ // v0.6.18 §3.74 / v0.6.26 §3.91:本回合实际生效的 prompt 预算 / 输出上限——内存不足时
903
+ // **只在本回合内**按 MEMORY_RECOVERY_LEVELS 逐级压小,不写回用户配置。
904
+ // turnBase* 是"起手值",两级压缩都相对它计算(不是逐级连乘),保证日志数值与文档一致。
905
+ let turnBaseBudget = startBudget;
906
+ let turnBaseMaxOutput = maxOutput;
907
+ let turnBudget = turnBaseBudget;
908
+ let turnMaxOutput = turnBaseMaxOutput;
909
+ // 子代理继承用:本回合当前生效值(压缩后立即更新,见 spawnTask)。
910
+ currentTurnBudget = turnBudget;
911
+ currentTurnMaxOutput = turnMaxOutput;
912
+ /** 本回合已用掉的内存降级重试次数(硬性上限 MAX_MEMORY_RETRIES = 2)。 */
790
913
  let memoryRetries = 0;
791
914
  lastMemoryRecovery = null;
792
915
  /** v0.6.3(M-16):本回合是否出现过"上游提前关流"(回答可能不完整)。
@@ -1378,6 +1501,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1378
1501
  localActive: gate.active,
1379
1502
  localQueued: gate.queued,
1380
1503
  localConcurrency: localConcurrencyLimit(cfg),
1504
+ // v0.6.26 §3.91:只在**本回合第一次**请求的预告里说明"预算来自哪天的失败学习"
1505
+ // (后续每轮重复同一条说明只会刷屏)。
1506
+ learned: turnProgress.llmCalls === 0 ? turnLearned : null,
1381
1507
  });
1382
1508
  turnProgress.forecast = f;
1383
1509
  // 首次请求一定说;之后只在命中告警线时说(否则每步一条横幅会刷屏)
@@ -1392,6 +1518,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1392
1518
  lastRequestStartAt = llmT0;
1393
1519
  turnProgress.llmCalls += 1;
1394
1520
  turnProgress.phase = '发请求(等首帧)';
1521
+ // v0.6.26 §3.91:本次请求实际发往的端点(费用护栏可能把 activeModel 降级到别家模型,
1522
+ // 端点学习必须记在**真正发请求的那个 baseUrl** 上,而不是 agent 创建时的模型)。
1523
+ const requestBaseUrl = resolveEndpointBaseUrl(cfg, activeModel);
1524
+ currentRequestBaseUrl = requestBaseUrl;
1395
1525
  try {
1396
1526
  res = await provider.chat({
1397
1527
  model: activeModel,
@@ -1399,6 +1529,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1399
1529
  tools: toolsFor(readOnlyPhase, turnStrippedSet),
1400
1530
  temperature,
1401
1531
  maxTokens: turnMaxOutput,
1532
+ // v0.6.26 §3.91:把本回合**实际生效**的 prompt 预算一并传给 provider。
1533
+ // openai-compatible 会忽略它(不属于协议字段);它的用途是让端点级学习、诊断与
1534
+ // 回归套件能断言"这一请求到底按多大预算发的"(子代理继承也靠它验证)。
1535
+ budget: turnBudget,
1402
1536
  // MiniMax P0:仅当 activeModel 声明支持 reasoning 才发送(否则 400 终止回合);'off' 显式禁用例外
1403
1537
  reasoningEffort: modelPreset(activeModel)?.supportsReasoning || reasoningEffort === 'off' ? reasoningEffort : undefined,
1404
1538
  // v0.6.18 §3.74:发生过内存降级后,本回合后续请求**一律压回单并发**(即便用户把
@@ -1484,16 +1618,18 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1484
1618
  // 直接终结合合并透出降级提示,不计入空轮、不注入续写重试(内存未释放必再 507,空烧请求)。
1485
1619
  const e = /** @type {any} */ (err);
1486
1620
  // ---------------------------------------------------------------------------
1487
- // v0.6.18 §3.74:**本地引擎内存/显存不足 → 压缩上下文 + 降输出 + 强制串行,重试一次**
1621
+ // v0.6.26 §3.91:**多级压缩(最多 2 次)+ 端点级学习 + 子代理继承**
1488
1622
  //
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/容量问题不是本地上下文能改的,压缩重试只会多烧一次请求。
1623
+ // 真机证据(~/.mingdao/logs/web-server.log,10-18 / 10-19 / 10-26 三次同形态):
1624
+ // 子代理「审计网络与安全防线」已跑 31 轮 / 50 次工具调用
1625
+ // 然后 `insufficient memory … aborted before the allocator wall`
1626
+ // 旧恢复只压一级(32,768 → 19,660)、只重试一次,仍失败就整回合失败。
1627
+ // 现在:
1628
+ // · 第 1 级:预算 ×0.6、输出 ×0.5;第 2 级:预算 ×0.35、输出 ×0.25(都相对起手值);
1629
+ // · 每级都打日志(第几次 / 压缩前后数值),**硬性上限 2 次**,绝不无限重试;
1630
+ // · 两级都失败 → 把最后尝试的预算写成该端点的 safeContextBudget(endpoint-limits.json),
1631
+ // 下一回合起手直接用它(不再从同一个注定失败的预算重撞一次);
1632
+ // · 云端端点不在此列:云 API 的 507/容量问题不是本地上下文能改的,压缩重试只会多烧一次请求。
1497
1633
  // ---------------------------------------------------------------------------
1498
1634
  let localEndpoint = false;
1499
1635
  try {
@@ -1506,41 +1642,85 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1506
1642
  const localMemory = describeUpstreamError(e, { isLocal: localEndpoint });
1507
1643
  if (localMemory.recoverableByShrinking) {
1508
1644
  if (memoryRetries >= MAX_MEMORY_RETRIES) {
1509
- // 第二次仍失败:不掩盖,把压缩前后数值、重试次数、具体建议一起抛给用户/日志。
1645
+ // 两级压缩都失败:**先学习、再报错**——学习值要立刻落盘并出现在建议里,
1646
+ // 否则用户看到的建议与下回合实际预算继续分裂(真机正是这样)。
1510
1647
  const rec = /** @type {any} */ (lastMemoryRecovery) || {};
1511
- const suggest = Math.max(4096, Math.floor(Number(rec.toBudget) > 0 ? Number(rec.toBudget) * 0.7 : 8192));
1648
+ let learned = /** @type {any} */ (null);
1649
+ try {
1650
+ learned = learnEndpointMemoryFailure(requestBaseUrl, {
1651
+ budget: Number(rec.toBudget) > 0 ? Number(rec.toBudget) : turnBudget,
1652
+ maxOutput: turnMaxOutput,
1653
+ attempts: Math.max(1, memoryRetries),
1654
+ at: Date.now(),
1655
+ });
1656
+ } catch (learnErr) {
1657
+ learned = {
1658
+ ok: false,
1659
+ record: null,
1660
+ file: '',
1661
+ error: `端点学习失败:${(/** @type {any} */ (learnErr))?.message || learnErr}——本轮按无学习值继续`,
1662
+ };
1663
+ }
1664
+ const learnedBudget = learned?.ok ? Math.floor(Number(learned.record?.safeContextBudget) || 0) : 0;
1665
+ // 建议文案必须与学习值**同一个数字**(describeUpstreamError 负责把 hint 对齐)。
1666
+ const finalDiag = describeUpstreamError(e, { isLocal: localEndpoint, learned: learned?.ok ? learned.record : null });
1667
+ const budgetChain =
1668
+ Array.isArray(rec.history) && rec.history.length
1669
+ ? rec.history.map((/** @type {any} */ h) => fmtTok(h.fromBudget)).concat(fmtTok(rec.toBudget)).join(' → ')
1670
+ : `${fmtTok(rec.fromBudget)} → ${fmtTok(rec.toBudget)}`;
1671
+ const outputChain =
1672
+ Array.isArray(rec.history) && rec.history.length
1673
+ ? rec.history.map((/** @type {any} */ h) => fmtTok(h.fromMaxOutput)).concat(fmtTok(rec.toMaxOutput)).join(' → ')
1674
+ : `${fmtTok(rec.fromMaxOutput)} → ${fmtTok(rec.toMaxOutput)}`;
1675
+ const learnedText =
1676
+ learned?.ok && learnedBudget > 0
1677
+ ? `已把该端点内存安全预算记为 ${fmtTok(learnedBudget)}(写入 ${learned.file}),下轮起手自动使用——` +
1678
+ `config.contextBudget 也可手动调到 ${fmtTok(learnedBudget)}。`
1679
+ : `⚠ ${learned?.error || '端点学习值未写入'}——本轮按无学习值继续,不阻断会话。`;
1512
1680
  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}`);
1681
+ `本地引擎内存不足:已压缩上下文 ${memoryRetries} 级(${budgetChain})、输出上限 ${outputChain},` +
1682
+ `并重试 ${memoryRetries} 次(硬性上限 ${MAX_MEMORY_RETRIES}),仍失败——不再重试。${learnedText}` +
1683
+ `请关闭其他占用显存的模型进程/并行会话,并在引擎侧调大 --ctx-size 或使用 q8 KV 量化。`;
1684
+ try { io.print(style(`⚠ ${finalDiag.message}\n ${finalDiag.hint || ''}\n ↩ ${detail}`, C.yellow)); } catch {}
1685
+ const enriched = new Error(`${finalDiag.message}\n ${finalDiag.hint || ''}\n ↩ ${detail}`);
1519
1686
  /** @type {any} */ (enriched).status = e?.status ?? 507;
1520
1687
  if (e?.headers !== undefined) /** @type {any} */ (enriched).headers = e.headers;
1521
1688
  throw enriched;
1522
1689
  }
1523
1690
  memoryRetries += 1;
1691
+ const target = memoryRecoveryTarget({ budget: turnBaseBudget, maxOutput: turnBaseMaxOutput, retry: memoryRetries });
1524
1692
  const fromBudget = turnBudget;
1525
1693
  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,
1694
+ turnBudget = target.budget;
1695
+ turnMaxOutput = target.maxOutput;
1696
+ // 子代理继承:压缩后的值必须立刻对后续 spawnTask 可见(见 spawnTask 的 cfg)。
1697
+ currentTurnBudget = turnBudget;
1698
+ currentTurnMaxOutput = turnMaxOutput;
1699
+ const attempt = {
1700
+ level: target.level,
1532
1701
  fromBudget,
1533
1702
  toBudget: turnBudget,
1534
1703
  fromMaxOutput,
1535
1704
  toMaxOutput: turnMaxOutput,
1536
1705
  at: Date.now(),
1537
1706
  };
1707
+ const prevHistory = Array.isArray(lastMemoryRecovery?.history) ? lastMemoryRecovery.history : [];
1708
+ lastMemoryRecovery = {
1709
+ attempts: memoryRetries,
1710
+ fromBudget: turnBaseBudget,
1711
+ toBudget: turnBudget,
1712
+ fromMaxOutput: turnBaseMaxOutput,
1713
+ toMaxOutput: turnMaxOutput,
1714
+ history: [...prevHistory, attempt],
1715
+ at: Date.now(),
1716
+ };
1538
1717
  const msg =
1539
- `♻ 因本地引擎内存不足,已压缩上下文(${fmtTok(fromBudget)} → ~${fmtTok(turnBudget)})、` +
1540
- `输出上限 ${fmtTok(fromMaxOutput)} → ${fmtTok(turnMaxOutput)},并重试一次(串行化:并发上限 1)`;
1718
+ `♻ 内存降级重试 第 ${memoryRetries}/${MAX_MEMORY_RETRIES} 次:因本地引擎内存不足,已压缩上下文` +
1719
+ `(${fmtTok(fromBudget)} → ~${fmtTok(turnBudget)})、输出上限 ${fmtTok(fromMaxOutput)} → ${fmtTok(turnMaxOutput)}` +
1720
+ `(串行化:并发上限 1)`;
1541
1721
  try { io.print(style(msg, C.yellow)); } catch {}
1542
1722
  turnNotes.push(msg);
1543
- turnProgress.phase = '内存降级重试(已压缩上下文)';
1723
+ turnProgress.phase = `内存降级重试(第 ${memoryRetries}/${MAX_MEMORY_RETRIES} 次压缩)`;
1544
1724
  turnProgress.memoryRecovery = lastMemoryRecovery;
1545
1725
  steps = Math.max(0, steps - 1); // 重试不额外消耗一步,避免 maxSteps=1 时根本没机会重试
1546
1726
  continue;
@@ -2334,14 +2514,62 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2334
2514
  try { io.print(style(budgetClampNotice, C.yellow)); } catch {}
2335
2515
  turnNotes.push(budgetClampNotice);
2336
2516
  }
2337
- const r = await runTurnInner(messages);
2517
+ // v0.6.26 §3.91:**每回合重读**端点学习值——同一 agent 连续多回合时,上一回合可能刚学到一个
2518
+ // 更低的预算(或成功上调了一个更高的值);只用 createAgent 时读到的那份会过时。
2519
+ // 学习值只在**低于**当前起手预算时接管(配置本来就小 → 用户选择优先)。
2520
+ let startBudget = budget;
2521
+ turnLearned = learnedStart;
2522
+ const freshLearned = endpointLimitFor(endpointBaseUrl);
2523
+ if (freshLearned.error) {
2524
+ try { io.print(style(`⚠ ${freshLearned.error}`, C.yellow)); } catch {}
2525
+ }
2526
+ const freshSafe = Math.floor(Number(freshLearned.record?.safeContextBudget) || 0);
2527
+ if (freshSafe > 0 && freshSafe < budget) {
2528
+ startBudget = freshSafe;
2529
+ turnLearned = { ...freshLearned.record, appliedBudget: freshSafe };
2530
+ }
2531
+ let r = /** @type {any} */ (null);
2532
+ try {
2533
+ r = await runTurnInner(messages, startBudget);
2534
+ } finally {
2535
+ turnLearned = null;
2536
+ currentTurnBudget = null;
2537
+ currentTurnMaxOutput = null;
2538
+ }
2539
+ // v0.6.26 §3.91(端点级学习·上调):**干净成功**(无内存压缩、无中断/无进展/截断)才计数,
2540
+ // 连续 3 次把学习值 +25%(上限 = 用户配置的预算)。上调后若再撞内存墙,
2541
+ // 两级压缩仍失败会把它重新学下来——降级与恢复都基于真实结果。
2542
+ if (!isSubagent && r && r.text && !r.aborted && !r.stalled && !r.truncated && !lastMemoryRecovery) {
2543
+ try {
2544
+ const success = noteEndpointSuccess(currentRequestBaseUrl || endpointBaseUrl, {
2545
+ budget: startBudget,
2546
+ ceiling: configuredBudget,
2547
+ });
2548
+ if (success?.raised && success.record) {
2549
+ const msg =
2550
+ `📈 该端点已连续 ${ENDPOINT_UPGRADE_SUCCESS_STREAK} 次干净成功:内存安全预算上调 ` +
2551
+ `${fmtTok(success.previous?.safeContextBudget)} → ${fmtTok(success.record.safeContextBudget)}(+25%),下轮起手使用`;
2552
+ try { io.print(style(msg, C.green)); } catch {}
2553
+ turnNotes.push(msg);
2554
+ }
2555
+ } catch {}
2556
+ }
2557
+ currentRequestBaseUrl = '';
2338
2558
  const notes = turnNotes.filter(Boolean);
2339
2559
  if (lastMemoryRecovery && r) {
2340
2560
  const rec = lastMemoryRecovery;
2341
2561
  const final = r.aborted ? '重试后被中断' : r.stalled ? '重试后无进展中止' : r.text ? '重试后成功' : '重试后仍无正文';
2562
+ const chain =
2563
+ Array.isArray(rec.history) && rec.history.length
2564
+ ? rec.history.map((/** @type {any} */ h) => fmtTok(h.fromBudget)).concat(fmtTok(rec.toBudget)).join(' → ')
2565
+ : `${fmtTok(rec.fromBudget)} → ${fmtTok(rec.toBudget)}`;
2566
+ const outChain =
2567
+ Array.isArray(rec.history) && rec.history.length
2568
+ ? rec.history.map((/** @type {any} */ h) => fmtTok(h.fromMaxOutput)).concat(fmtTok(rec.toMaxOutput)).join(' → ')
2569
+ : `${fmtTok(rec.fromMaxOutput)} → ${fmtTok(rec.toMaxOutput)}`;
2342
2570
  notes.push(
2343
- `↩ 内存降级重试(${Number(rec.attempts) || 1} 次):上下文预算 ${fmtTok(rec.fromBudget)} → ${fmtTok(rec.toBudget)}、` +
2344
- `输出上限 ${fmtTok(rec.fromMaxOutput)} → ${fmtTok(rec.toMaxOutput)};${final}。`
2571
+ `↩ 内存降级重试(${Number(rec.attempts) || 0} 次,最多 ${MAX_MEMORY_RETRIES} 次,已压缩上下文):预算 ${chain}、` +
2572
+ `输出上限 ${outChain};${final}。`
2345
2573
  );
2346
2574
  }
2347
2575
  if (notes.length && r && typeof r === 'object') {
@@ -18,6 +18,7 @@ import { modelPreset, providerPreset } from '../models.js';
18
18
  import { mingdaoHome } from '../config.js';
19
19
  import { resolveApiKey, getStoredKey } from '../credentials.js';
20
20
  import { isLocalBaseUrl } from '../model-caps.js';
21
+ import { atomicWriteJsonSync } from '../atomic-write.js';
21
22
 
22
23
  // ---------------------------------------------------------------------------
23
24
  // 本地端点并发闸门(v0.6.15,登记 §3.59 四条待办之首)
@@ -254,6 +255,220 @@ export function __resetLocalGates() {
254
255
  localGates.clear();
255
256
  }
256
257
 
258
+ // ---------------------------------------------------------------------------
259
+ // 端点级「内存安全预算」学习(v0.6.26 §3.91)
260
+ //
261
+ // 真机证据(~/.mingdao/logs/web-server.log,三次同一形态:10-18 / 10-19 / 10-26):
262
+ // 08:59:14 chat 进度 … 子代理「审计网络与安全防线」已跑 31 轮 / 50 次工具调用
263
+ // 09:00:22 chat 错误 … insufficient memory … aborted before the allocator wall
264
+ // ↩ 已压缩上下文(32,768 → ~19,660)、输出上限 4,096 → 2,048,并重试 1 次,仍失败
265
+ // 建议:把 config.contextBudget 调到 13,762 左右…
266
+ // 问题不在"没有压缩",而在**每一回合都从同一个注定失败的预算重新撞一次**:用户看到了建议,
267
+ // 但内核自己不记住;下一次开新会话(或同一端点的另一个模型)又按 32,768 发。
268
+ //
269
+ // 这里把「该 baseUrl 的内存安全预算」持久化到 <mingdao-home>/endpoint-limits.json:
270
+ // · 键 = 归一化后的 baseUrl(同一个端点的不同写法命中同一条记录);
271
+ // · 值 = { safeContextBudget, learnedAt, source: 'memory-failure', … };
272
+ // · **两级压缩后仍失败**才写入(调用方保证),后续回合直接以 safeContextBudget 起手;
273
+ // · 之后连续 3 个"干净成功"的回合把学习值 +25%(上限 = 用户配置的预算),
274
+ // 如果上调后又撞内存墙,两级压缩仍失败会把它重新学下来——降级/恢复都基于证据。
275
+ // · 文件读/写失败**绝不阻断会话**:所有函数都返回 {ok, error},由调用方降级为"无学习值"并打 warn。
276
+ // ---------------------------------------------------------------------------
277
+
278
+ /** 学习文件名(放在 <mingdao-home> 下,与 config.json 同级)。 */
279
+ export const ENDPOINT_LIMITS_FILE = 'endpoint-limits.json';
280
+ /** 文件格式版本(未来字段演进时用于识别旧文件)。 */
281
+ export const ENDPOINT_LIMITS_VERSION = 1;
282
+ /** 学习值上调策略:连续这么多次干净成功,才允许 +25%。 */
283
+ export const ENDPOINT_UPGRADE_SUCCESS_STREAK = 3;
284
+ /** 每次上调的比例(只作用于学习值本身,且不超过用户配置预算)。 */
285
+ export const ENDPOINT_UPGRADE_RATIO = 1.25;
286
+ /** 学习值下限:低于它连系统提示 + 一轮工具结果都放不下,学习就没有意义。 */
287
+ export const ENDPOINT_MIN_SAFE_BUDGET = 1024;
288
+
289
+ /** 学习文件路径(测试可换 MINGDAO_HOME 隔离)。 */
290
+ export function endpointLimitsFile() {
291
+ return path.join(mingdaoHome(), ENDPOINT_LIMITS_FILE);
292
+ }
293
+
294
+ /** 端点键:归一化 baseUrl(去尾部斜杠),与本地并发闸门同一口径。 */
295
+ export function endpointKeyOf(/** @type {any} */ baseUrl) {
296
+ return normalizeLocalBaseUrl(baseUrl);
297
+ }
298
+
299
+ /**
300
+ * 读取端点学习表。**任何读失败都降级为空表 + error**,绝不抛:
301
+ * · 不存在(ENOENT)→ ok:true、空表(首次运行,不是错误);
302
+ * · 不可读 / 解析失败 / 结构不对 → ok:false、空表、error 带文件路径与原因。
303
+ * 调用方拿到 ok:false 时必须按"无学习值"继续,只打一条 warn。
304
+ * @returns {{ ok: boolean, exists: boolean, file: string, version: number, endpoints: Record<string, any>, error: string|null }}
305
+ */
306
+ export function readEndpointLimits() {
307
+ const file = endpointLimitsFile();
308
+ /** @type {{ ok: boolean, exists: boolean, file: string, version: number, endpoints: Record<string, any>, error: string|null }} */
309
+ const base = { ok: true, exists: false, file, version: ENDPOINT_LIMITS_VERSION, endpoints: {}, error: null };
310
+ let raw;
311
+ try {
312
+ raw = fs.readFileSync(file, 'utf8');
313
+ } catch (err) {
314
+ if (/** @type {any} */ (err)?.code === 'ENOENT') return base;
315
+ return {
316
+ ...base,
317
+ ok: false,
318
+ error: `端点学习文件读取失败(${file}):${(/** @type {any} */ (err))?.message || err}——本轮按无学习值继续`,
319
+ };
320
+ }
321
+ // BOM:合法 JSON + 文件头 BOM = JSON.parse 直接抛(与 config.json 同一处理)
322
+ const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
323
+ let data;
324
+ try {
325
+ data = JSON.parse(text);
326
+ } catch (err) {
327
+ return {
328
+ ...base,
329
+ exists: true,
330
+ ok: false,
331
+ error: `端点学习文件不能解析(${file}):${(/** @type {any} */ (err))?.message || err}——本轮按无学习值继续(文件保持原样,避免覆盖可人工修复的数据)`,
332
+ };
333
+ }
334
+ const eps = data?.endpoints;
335
+ if (!eps || typeof eps !== 'object' || Array.isArray(eps)) {
336
+ return {
337
+ ...base,
338
+ exists: true,
339
+ ok: false,
340
+ error: `端点学习文件缺少 endpoints 对象(${file})——本轮按无学习值继续(文件保持原样)`,
341
+ };
342
+ }
343
+ /** @type {Record<string, any>} */
344
+ const endpoints = {};
345
+ for (const [key, value] of Object.entries(eps)) {
346
+ const n = Number(/** @type {any} */ (value)?.safeContextBudget);
347
+ if (!(n > 0)) continue; // 单条记录坏了只跳过它,不拖垮整张表
348
+ endpoints[endpointKeyOf(key)] = {
349
+ .../** @type {any} */ (value),
350
+ safeContextBudget: Math.max(ENDPOINT_MIN_SAFE_BUDGET, Math.floor(n)),
351
+ };
352
+ }
353
+ return { ...base, exists: true, version: Number(data?.version) || ENDPOINT_LIMITS_VERSION, endpoints };
354
+ }
355
+
356
+ /** 写整张学习表(原子写 + 0600)。失败返回 {ok:false,error},绝不抛。 */
357
+ function writeEndpointLimits(/** @type {Record<string, any>} */ endpoints) {
358
+ const file = endpointLimitsFile();
359
+ try {
360
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
361
+ atomicWriteJsonSync(file, { version: ENDPOINT_LIMITS_VERSION, endpoints });
362
+ return { ok: true, error: null, file };
363
+ } catch (err) {
364
+ return {
365
+ ok: false,
366
+ error: `端点学习文件写入失败(${file}):${(/** @type {any} */ (err))?.message || err}——本轮按无学习值继续`,
367
+ file,
368
+ };
369
+ }
370
+ }
371
+
372
+ /**
373
+ * 查该端点的学习值(纯读,不写入)。
374
+ * @param {any} baseUrl
375
+ * @returns {{ key: string, record: any|null, ok: boolean, error: string|null, file: string }}
376
+ */
377
+ export function endpointLimitFor(/** @type {any} */ baseUrl) {
378
+ const key = endpointKeyOf(baseUrl);
379
+ const file = endpointLimitsFile();
380
+ if (!key) {
381
+ // 没有 baseUrl(例如自定义 Provider 模块):不是错误,只是没有可学习的键。
382
+ return { key: '', record: null, ok: true, error: null, file };
383
+ }
384
+ const r = readEndpointLimits();
385
+ return { key, record: r.endpoints[key] || null, ok: r.ok, error: r.error, file: r.file };
386
+ }
387
+
388
+ /**
389
+ * 记录一次「多级压缩后仍失败」:把**最后尝试的预算**写成该端点的 safeContextBudget。
390
+ *
391
+ * 为什么记最后尝试的值而不是原配置:下一次回合的起手预算必须来自"已经压到的最低一级",
392
+ * 否则同一个注定失败的预算会每回合重撞一次(真机三次同形态失败的根因)。
393
+ * 学习值是**起手上限**,不是"保证成功"——若它仍失败,下一回合会在它基础上继续按级压缩;
394
+ * 若连续失败,值会逐回合向下棘轮,直到某一回合成功为止。
395
+ * @param {any} baseUrl
396
+ * @param {{ budget: number, maxOutput?: number|null, attempts?: number, at?: number, source?: string }} [opts]
397
+ * @returns {{ ok: boolean, key: string, record: any|null, previous: any|null, error: string|null, file: string }}
398
+ */
399
+ export function learnEndpointMemoryFailure(/** @type {any} */ baseUrl, { budget, maxOutput = null, attempts = 2, at = Date.now(), source = 'memory-failure' } = /** @type {any} */ ({})) {
400
+ const key = endpointKeyOf(baseUrl);
401
+ const file = endpointLimitsFile();
402
+ const empty = { ok: false, key, record: null, previous: null, error: null, file };
403
+ if (!key) return { ...empty, ok: true, error: null };
404
+ const rawBudget = Number(budget);
405
+ if (!(rawBudget > 0)) {
406
+ return { ...empty, error: `端点学习值未写入:预算无效(${String(budget)})——本轮按无学习值继续` };
407
+ }
408
+ const r = readEndpointLimits();
409
+ if (!r.ok) {
410
+ // 文件坏了/读不出:**不覆盖**(可能还能人工救回来),只降级为无学习值。
411
+ return { ...empty, error: `${r.error};本次不写入,避免覆盖可能需要人工修复的文件` };
412
+ }
413
+ const previous = r.endpoints[key] || null;
414
+ const nowIso = new Date(Number(at) || Date.now()).toISOString();
415
+ const record = {
416
+ safeContextBudget: Math.max(ENDPOINT_MIN_SAFE_BUDGET, Math.floor(rawBudget)),
417
+ learnedAt: nowIso,
418
+ source,
419
+ attempts: Math.max(1, Math.floor(Number(attempts) || 1)),
420
+ ...(Number(maxOutput) > 0 ? { maxOutputTokens: Math.floor(Number(maxOutput)) } : {}),
421
+ failures: (Number(previous?.failures) || 0) + 1,
422
+ successStreak: 0,
423
+ lastFailedAt: nowIso,
424
+ };
425
+ const w = writeEndpointLimits({ ...r.endpoints, [key]: record });
426
+ return { ok: w.ok, key, record: w.ok ? record : null, previous, error: w.error, file };
427
+ }
428
+
429
+ /**
430
+ * 记录一次**干净成功**(本回合既没有内存压缩、也没有中断/无进展):连续
431
+ * ENDPOINT_UPGRADE_SUCCESS_STREAK 次后把学习值上调 ENDPOINT_UPGRADE_RATIO(+25%)。
432
+ *
433
+ * 上调的上限是 `ceiling`(调用方传**用户配置的预算**,即"用户本来想要多大"),
434
+ * 所以学习值只会朝用户明确要过的方向恢复,不会一路涨到窗口上限。
435
+ * 这不是"猜一个更大的值去冒险":上调后若再次撞内存墙,两级压缩仍失败会把它重新学下来
436
+ * (见 learnEndpointMemoryFailure)——降级有证据、恢复也有证据,形成闭环。
437
+ *
438
+ * 没有学习值的端点不写文件(不为"成功"凭空造学习值)。
439
+ * @param {any} baseUrl
440
+ * @param {{ budget: number, ceiling?: number, at?: number }} [opts]
441
+ * @returns {{ ok: boolean, key: string, record: any|null, previous: any|null, raised: boolean, streak: number, error: string|null, file: string }}
442
+ */
443
+ export function noteEndpointSuccess(/** @type {any} */ baseUrl, { budget, ceiling = 0, at = Date.now() } = /** @type {any} */ ({})) {
444
+ const key = endpointKeyOf(baseUrl);
445
+ const file = endpointLimitsFile();
446
+ const empty = { ok: true, key, record: null, previous: null, raised: false, streak: 0, error: null, file };
447
+ if (!key) return empty;
448
+ const r = readEndpointLimits();
449
+ if (!r.ok) return { ...empty, ok: false, error: r.error };
450
+ const previous = r.endpoints[key] || null;
451
+ if (!previous) return empty; // 没学过就没有可上调的值(不为"成功"凭空造学习值)
452
+ const used = Math.floor(Number(budget) || 0);
453
+ const cap = Math.max(Math.floor(Number(ceiling) || 0), used, Number(previous.safeContextBudget) || 0);
454
+ const nowIso = new Date(Number(at) || Date.now()).toISOString();
455
+ const streak = (Number(previous.successStreak) || 0) + 1;
456
+ /** @type {any} */
457
+ let next = { ...previous, successStreak: streak, lastSuccessAt: nowIso, lastSuccessBudget: used };
458
+ let raised = false;
459
+ if (streak >= ENDPOINT_UPGRADE_SUCCESS_STREAK) {
460
+ const target = Math.min(Math.floor(Number(previous.safeContextBudget) * ENDPOINT_UPGRADE_RATIO), cap);
461
+ if (target > Number(previous.safeContextBudget)) {
462
+ next = { ...next, safeContextBudget: target, learnedAt: nowIso, source: 'success-upgrade', successStreak: 0 };
463
+ raised = true;
464
+ } else {
465
+ next = { ...next, successStreak: 0 };
466
+ }
467
+ }
468
+ const w = writeEndpointLimits({ ...r.endpoints, [key]: next });
469
+ return { ok: w.ok, key, record: w.ok ? next : null, previous, raised: w.ok && raised, streak, error: w.error, file };
470
+ }
471
+
257
472
  // v0.6.3(下游 Dify 工作流反馈):`customModels` 的条目其实是**两种东西**,此前混为一谈——
258
473
  // · **声明式端点**:写了 baseUrl / envKey / apiKey / headers 等传输字段 → 确实是"自定义
259
474
  // OpenAI 兼容端点",优先于内置预设(原行为,不变);
package/src/web/app.js CHANGED
@@ -349,7 +349,7 @@ function toggleRecentSessions() {
349
349
  writeRecentCollapsed(storage, recentCollapsed);
350
350
  syncRecentCollapsed(recentCollapsed);
351
351
  }
352
- /** 三档断点:≥1180 三栏 / 900–1180 左栏自动折叠 / <900 抽屉。 */
352
+ /** 左栏三档断点:≥1180 展开(240) / 900–1180 自动折叠(48) / <900 抽屉。右栏收起点见 layoutClassesFor(<1100)。 */
353
353
  function sideNavBreakpoint(width) {
354
354
  const w = Number(width) || 0;
355
355
  if (w >= 1180) return 'wide';
@@ -1435,7 +1435,7 @@ function mergeSubagentProgress(pending, sub) {
1435
1435
  status: 'running',
1436
1436
  });
1437
1437
  }
1438
- /** 窄窗口(<1180 右栏收起)且右栏不可见时,轨迹落到对话流末尾的可折叠区块。 */
1438
+ /** 窄窗口(<1100 右栏收起)且右栏不可见时,轨迹落到对话流末尾的可折叠区块。 */
1439
1439
  function traceDockVisible(width, panelVisible) {
1440
1440
  return layoutClassesFor(width).narrowRun && !panelVisible;
1441
1441
  }
@@ -1444,10 +1444,11 @@ function traceFocusTarget(width, panelVisible) {
1444
1444
  return traceDockVisible(width, panelVisible) ? 'dock' : 'panel';
1445
1445
  }
1446
1446
 
1447
- // —— 窄屏断点:<900 侧栏收起;<1180 右栏收起(同一判据供 initResponsive 与断言使用) ——
1447
+ // —— 窄屏断点:<900 侧栏收起;<1100 右栏收起(同一判据供 initResponsive 与断言使用) ——
1448
+ // §3.92:右栏收起点由 1180 下调到 1100,1180 整点恢复三栏(1180–1101 右栏 260px)。
1448
1449
  function layoutClassesFor(width) {
1449
1450
  const w = Number(width) || 0;
1450
- return { narrow: w < 900, narrowRun: w < 1180 };
1451
+ return { narrow: w < 900, narrowRun: w < 1100 };
1451
1452
  }
1452
1453
 
1453
1454
  // ============================================================================
@@ -1547,7 +1548,7 @@ function initTheme() {
1547
1548
  }
1548
1549
  initTheme();
1549
1550
 
1550
- // —— 右栏「本轮运行」:<1180 收起为抽屉;宽屏默认展开;不做任何新轮询 ——
1551
+ // —— 右栏「本轮运行」:<1100 收起为抽屉;宽屏默认展开;不做任何新轮询 ——
1551
1552
  function syncRunPanel() {
1552
1553
  const el = $('#runPanel'); if (!el) return;
1553
1554
  // §3.82:四个面板都在右栏标签页内,右边缘只剩右栏一个抽屉(显隐由 syncPanelLayout 统一算)。
@@ -1803,7 +1804,7 @@ $('#rpCompact').onclick = () => renderBanner({ text: '⚠ Web 端没有 /compact
1803
1804
  // §3.73 轨迹 / 子 Agent 收纳:
1804
1805
  // · 对话流:每轮只留**一行**摘要(.trace-row > .trace-summary),不再内联工具卡;
1805
1806
  // · 宽屏:详情在右栏「轨迹 / 子Agent」两个可折叠区;
1806
- // · 窄屏(<1180 右栏收起):同样的两块在对话流末尾的 #traceDock 里(JS 切换 hidden)。
1807
+ // · 窄屏(<1100 右栏收起):同样的两块在对话流末尾的 #traceDock 里(JS 切换 hidden)。
1807
1808
  // · 统计口径:对话流摘要与右栏轮头都调用 traceRoundForMsg → traceRoundView,只有一套。
1808
1809
  // ============================================================================
1809
1810
  function traceRoundForMsg(msg, now) {
@@ -471,10 +471,13 @@ body.run-open #scrollBottomBtn{right:326px}
471
471
  .td-fold-body .tr-round-body .tl-res{padding:0 12px 5px 138px;font-size:11.5px}
472
472
  .td-fold-body .tr-round-body .tl-out{padding:0 12px 9px 138px}
473
473
 
474
- @media (max-width:1180px){
475
- body.run-open{padding-right:0}
476
- #runPanel{display:none}
477
- body.run-drawer #runPanel{display:flex;top:52px;width:300px;max-width:92vw;z-index:60;box-shadow:-12px 0 32px rgba(0,0,0,.4)}
474
+ /* §3.92:1180 整点必须仍是三栏——右栏收起点从 1180 下调到 1100。
475
+ 媒体查询按 max-width:1100px 书写(一条规则覆盖到 1100),显隐由 body.narrow-run 收口:
476
+ narrow-run 只在 JS 的 w < 1100 时挂上,因此 1100 整点不命中收起规则,仍是三栏。 */
477
+ @media (max-width:1100px){
478
+ body.narrow-run.run-open{padding-right:0}
479
+ body.narrow-run #runPanel{display:none}
480
+ body.narrow-run.run-drawer #runPanel{display:flex;top:52px;width:300px;max-width:92vw;z-index:60;box-shadow:-12px 0 32px rgba(0,0,0,.4)}
478
481
  }
479
482
 
480
483
  /* —— 会话设置弹层(收编模型 / 预设 / 权限三件套)——
@@ -482,7 +485,15 @@ body.run-open #scrollBottomBtn{right:326px}
482
485
  effective 13px/11px + 8px 10px、foot 9px 13px);圆角保持设计稿 14px(sheet)/8px(effective)。 */
483
486
  #sessSheet{position:fixed;right:18px;bottom:110px;width:min(400px,calc(100vw - 24px));max-height:min(72vh,560px);background:var(--panel);border:1px solid var(--border);border-radius:14px;box-shadow:0 16px 48px rgba(0,0,0,.5);z-index:70;display:flex;flex-direction:column;overflow:hidden}
484
487
  body.run-open #sessSheet{right:318px}
485
- @media (max-width:1180px){body.run-open #sessSheet{right:18px}}
488
+ @media (max-width:1100px){body.narrow-run.run-open #sessSheet{right:18px}}
489
+ /* §3.92:1101–1180 右栏收窄到 260px 但仍可见(内部照常滚动),正文只让出 260px,
490
+ 避免 1180 附近正文过窄;1180 整点因此仍是三栏。 */
491
+ @media (min-width:1101px) and (max-width:1180px){
492
+ #runPanel{width:260px}
493
+ body.run-open{padding-right:260px}
494
+ body.run-open #scrollBottomBtn{right:286px}
495
+ body.run-open #sessSheet{right:278px}
496
+ }
486
497
  .ss-head{display:flex;align-items:center;gap:8px;padding:11px 13px;border-bottom:1px solid var(--border);font-size:13px;font-weight:600;color:var(--text);flex:none}
487
498
  .ss-head .ss-sub{font-size:11px;color:var(--faint);font-weight:400}
488
499
  .ss-head button{margin-left:auto;padding:1px 8px;font-size:11px}
@@ -541,7 +552,7 @@ body.run-open #sessSheet{right:318px}
541
552
  .diag-row button{flex:none;padding:1px 8px;font-size:11px}
542
553
  .diag-note{font-size:11.5px;color:var(--faint);margin-top:8px;line-height:1.6}
543
554
 
544
- /* —— 窄屏断点:<1180 右栏收起;<900 侧栏(图标轨/次要控件)收起 —— */
555
+ /* —— 窄屏断点:<1180 右栏(宽度 260)的副标题收起;右栏本体保留到 1100。<900 侧栏(图标轨/次要控件)收起 —— */
545
556
  @media (max-width:1180px){
546
557
  body{padding-right:0}
547
558
  .rp-head .rp-sub{display:none}
@@ -702,7 +713,10 @@ body.side-drawer #trajRail #sideNavRailBtn{display:flex}
702
713
  body.side-wide main,body.side-wide footer{margin-left:282px}
703
714
  body.side-wide.side-collapsed main,body.side-wide.side-collapsed footer{margin-left:90px}
704
715
  body.side-mid main,body.side-mid footer{margin-left:90px}
705
- body.side-drawer main,body.side-drawer footer{margin-left:0}
716
+ /* §3.92:<900 左栏收成抽屉时,44px 窄条只做抽屉开关,不再压住居中正文的左缘。
717
+ 主区/输入区右移 48px(44 窄条 + 4px 间隙);只加偏移不设宽度,窄屏不会出现横向滚动。
718
+ 窄条本身仍是 position:fixed,不参与文档流。 */
719
+ body.side-drawer main,body.side-drawer footer{margin-left:48px}
706
720
  /* 折叠态:只留图标 + tooltip(label/分组标题隐藏;折叠按钮本身是图标,保留用于展开) */
707
721
  body.side-collapsed .sn-title,
708
722
  body.side-mid .sn-title,
@@ -796,7 +810,7 @@ body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions
796
810
  </div>
797
811
  </div>
798
812
  <div id="chat"></div>
799
- <!-- §3.73:窄屏(<1180)右栏收起时,轨迹/子Agent 落在对话流末尾的这一个可折叠区块里,
813
+ <!-- §3.73:窄屏(<1100)右栏收起时,轨迹/子Agent 落在对话流末尾的这一个可折叠区块里,
800
814
  不塞回每条消息之间。宽屏隐藏(详情在 #runPanel)。 -->
801
815
  <section id="traceDock" aria-label="轨迹与子 Agent" hidden>
802
816
  <button id="tdToggle" type="button" aria-expanded="true">🧭 轨迹与子 Agent<span class="td-meta" id="tdMeta">—</span><span class="td-tri" aria-hidden="true">⌄</span></button>
@@ -847,7 +861,8 @@ body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions
847
861
  </footer>
848
862
 
849
863
  <!-- P1-4 右栏「本轮运行」(v0.6.19 §3.70):与底部状态条**同源**(runPanelView → runStripView → runState),
850
- ≥1180 默认展开并推开正文;<1180 收起为顶栏「运行」chip,点开是覆盖式抽屉。无新增轮询。 -->
864
+ ≥1100 默认展开并推开正文(1101–1180 收窄到 260px);<1100 收起为顶栏「运行」chip,
865
+ 点开是覆盖式抽屉。无新增轮询。 -->
851
866
  <aside id="runPanel" aria-label="本轮运行" hidden>
852
867
  <div class="rp-head"><span class="rp-live" aria-hidden="true"></span><span>本轮运行</span><span class="rp-sub">指标口径:本轮(非累计)</span><button id="rpCollapse" type="button" title="收起右栏">收起</button></div>
853
868
  <!-- §3.82:空结构占位(hidden)。真实的「会话配置 + 本轮数据」按 §3.90 收进下方可折叠