mingdao-harness 0.6.25 → 0.6.27
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 +48 -13
- package/package.json +1 -1
- package/src/agent.js +280 -52
- package/src/providers/index.js +215 -0
- package/src/web/app.js +12 -8
- package/src/web/index.html +48 -21
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
|
-
####
|
|
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.
|
|
786
|
-
|
|
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
|
-
|
|
|
791
|
-
|
|
|
792
|
-
|
|
|
793
|
-
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
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 {
|
|
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
|
-
/**
|
|
93
|
+
/** 本地引擎内存不足时的**多级压缩**策略(v0.6.26 §3.91)。
|
|
83
94
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
597
|
-
// batch28 ⑥ 共同钉住的形状(改缩进/换写法会让既有变异变成"变异点未找到"
|
|
598
|
-
//
|
|
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
|
-
//
|
|
787
|
-
|
|
788
|
-
let
|
|
789
|
-
|
|
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.
|
|
1621
|
+
// v0.6.26 §3.91:**多级压缩(最多 2 次)+ 端点级学习 + 子代理继承**
|
|
1488
1622
|
//
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1494
|
-
// ·
|
|
1495
|
-
// ·
|
|
1496
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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
|
-
`♻
|
|
1540
|
-
|
|
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
|
-
|
|
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) ||
|
|
2344
|
-
`输出上限 ${
|
|
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') {
|
package/src/providers/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
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';
|
|
@@ -498,6 +498,9 @@ function initSideNav() {
|
|
|
498
498
|
if (collapse) collapse.onclick = toggleSideNav;
|
|
499
499
|
const recentToggle = $('#sideNavRecentToggle');
|
|
500
500
|
if (recentToggle) recentToggle.onclick = toggleRecentSessions;
|
|
501
|
+
// v0.6.27 §3.94:会话管理入口从左栏标题右侧进入,复用既有的 openSessModal(能力不新增、不丢失)。
|
|
502
|
+
const sideSessMgr = $('#sideNavSessMgr');
|
|
503
|
+
if (sideSessMgr) sideSessMgr.onclick = openSessModal;
|
|
501
504
|
const rail = $('#sideNavRailBtn');
|
|
502
505
|
if (rail) rail.onclick = toggleSideNav;
|
|
503
506
|
const ws = $('#sideNavWs');
|
|
@@ -1435,7 +1438,7 @@ function mergeSubagentProgress(pending, sub) {
|
|
|
1435
1438
|
status: 'running',
|
|
1436
1439
|
});
|
|
1437
1440
|
}
|
|
1438
|
-
/** 窄窗口(<
|
|
1441
|
+
/** 窄窗口(<1100 右栏收起)且右栏不可见时,轨迹落到对话流末尾的可折叠区块。 */
|
|
1439
1442
|
function traceDockVisible(width, panelVisible) {
|
|
1440
1443
|
return layoutClassesFor(width).narrowRun && !panelVisible;
|
|
1441
1444
|
}
|
|
@@ -1444,10 +1447,11 @@ function traceFocusTarget(width, panelVisible) {
|
|
|
1444
1447
|
return traceDockVisible(width, panelVisible) ? 'dock' : 'panel';
|
|
1445
1448
|
}
|
|
1446
1449
|
|
|
1447
|
-
// —— 窄屏断点:<900 侧栏收起;<
|
|
1450
|
+
// —— 窄屏断点:<900 侧栏收起;<1100 右栏收起(同一判据供 initResponsive 与断言使用) ——
|
|
1451
|
+
// §3.92:右栏收起点由 1180 下调到 1100,1180 整点恢复三栏(1180–1101 右栏 260px)。
|
|
1448
1452
|
function layoutClassesFor(width) {
|
|
1449
1453
|
const w = Number(width) || 0;
|
|
1450
|
-
return { narrow: w < 900, narrowRun: w <
|
|
1454
|
+
return { narrow: w < 900, narrowRun: w < 1100 };
|
|
1451
1455
|
}
|
|
1452
1456
|
|
|
1453
1457
|
// ============================================================================
|
|
@@ -1547,7 +1551,7 @@ function initTheme() {
|
|
|
1547
1551
|
}
|
|
1548
1552
|
initTheme();
|
|
1549
1553
|
|
|
1550
|
-
// —— 右栏「本轮运行」:<
|
|
1554
|
+
// —— 右栏「本轮运行」:<1100 收起为抽屉;宽屏默认展开;不做任何新轮询 ——
|
|
1551
1555
|
function syncRunPanel() {
|
|
1552
1556
|
const el = $('#runPanel'); if (!el) return;
|
|
1553
1557
|
// §3.82:四个面板都在右栏标签页内,右边缘只剩右栏一个抽屉(显隐由 syncPanelLayout 统一算)。
|
|
@@ -1803,7 +1807,7 @@ $('#rpCompact').onclick = () => renderBanner({ text: '⚠ Web 端没有 /compact
|
|
|
1803
1807
|
// §3.73 轨迹 / 子 Agent 收纳:
|
|
1804
1808
|
// · 对话流:每轮只留**一行**摘要(.trace-row > .trace-summary),不再内联工具卡;
|
|
1805
1809
|
// · 宽屏:详情在右栏「轨迹 / 子Agent」两个可折叠区;
|
|
1806
|
-
// · 窄屏(<
|
|
1810
|
+
// · 窄屏(<1100 右栏收起):同样的两块在对话流末尾的 #traceDock 里(JS 切换 hidden)。
|
|
1807
1811
|
// · 统计口径:对话流摘要与右栏轮头都调用 traceRoundForMsg → traceRoundView,只有一套。
|
|
1808
1812
|
// ============================================================================
|
|
1809
1813
|
function traceRoundForMsg(msg, now) {
|
|
@@ -2566,8 +2570,8 @@ async function refreshCostBadge(){
|
|
|
2566
2570
|
}
|
|
2567
2571
|
refreshCostBadge(); setInterval(refreshCostBadge, 15000);
|
|
2568
2572
|
// §3.82:顶栏费用徽标 = 打开右栏「仪表盘」标签(原有 📊 内容仍在 #dashPanel 里;关闭用 ✕/收起)。
|
|
2573
|
+
// v0.6.27 §3.94:旧窄条 #trajRailBtn/#subRailBtn 已删除,不再在这里清它们的 on 态(否则空引用抛错、标签切不动)。
|
|
2569
2574
|
$('#costBadge').onclick=()=>{
|
|
2570
|
-
$('#trajRailBtn').classList.remove('on'); $('#subRailBtn').classList.remove('on');
|
|
2571
2575
|
activateRailTab('dash');
|
|
2572
2576
|
};
|
|
2573
2577
|
$('#dashClose').onclick=()=>{ setRunPanel(false); };
|
|
@@ -2583,8 +2587,8 @@ async function refreshStatusBar(){
|
|
|
2583
2587
|
}
|
|
2584
2588
|
refreshStatusBar(); setInterval(refreshStatusBar, 15000);
|
|
2585
2589
|
// §3.82:顶栏「任务」= 打开右栏「任务」标签(右栏收起时也会展开;关闭用 ✕/收起)。
|
|
2590
|
+
// v0.6.27 §3.94:同上,旧窄条按钮已删除,不再清其 on 态。
|
|
2586
2591
|
$('#tasksBtn').onclick=()=>{
|
|
2587
|
-
$('#trajRailBtn').classList.remove('on'); $('#subRailBtn').classList.remove('on');
|
|
2588
2592
|
activateRailTab('tasks');
|
|
2589
2593
|
};
|
|
2590
2594
|
$('#tpClose').onclick=()=>{ setRunPanel(false); };
|
package/src/web/index.html
CHANGED
|
@@ -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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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:
|
|
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
|
|
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}
|
|
@@ -671,6 +682,11 @@ body.run-open #sessSheet{right:318px}
|
|
|
671
682
|
#sideNavWs{flex:1;min-width:0;max-width:none;background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:5px 6px;font-size:12px}
|
|
672
683
|
#sideNavWsIcon{display:none;align-items:center;justify-content:center;width:36px;height:30px;padding:0;border-radius:8px;background:var(--bg3);font-size:14px;line-height:1}
|
|
673
684
|
#sideNav .sn-sec-title{flex:none;font-size:10.5px;color:var(--dim);letter-spacing:.05em;padding:2px 6px;white-space:nowrap}
|
|
685
|
+
/* §3.94:「最近会话」标题右侧的会话管理入口(从顶栏移入;复用 openSessModal,不新增能力)。 */
|
|
686
|
+
.sn-sec-row{display:flex;align-items:center;gap:2px;min-width:0}
|
|
687
|
+
.sn-sec-row #sideNavRecentToggle{flex:1 1 auto;min-width:0}
|
|
688
|
+
#sideNavSessMgr{flex:none;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dim);font-size:12px;line-height:1;cursor:pointer}
|
|
689
|
+
#sideNavSessMgr:hover{background:var(--bg3);color:var(--text)}
|
|
674
690
|
/* §3.90:「最近会话」标题变成折叠开关;折叠后标题与计数仍在,只藏列表项。 */
|
|
675
691
|
#sideNav button.sn-sec-title{display:flex;align-items:center;gap:6px;width:100%;min-width:0;border:0;border-radius:6px;background:transparent;font-family:inherit;font-weight:600;cursor:pointer;text-align:left}
|
|
676
692
|
#sideNav button.sn-sec-title:hover{color:var(--text);background:var(--bg3)}
|
|
@@ -702,7 +718,10 @@ body.side-drawer #trajRail #sideNavRailBtn{display:flex}
|
|
|
702
718
|
body.side-wide main,body.side-wide footer{margin-left:282px}
|
|
703
719
|
body.side-wide.side-collapsed main,body.side-wide.side-collapsed footer{margin-left:90px}
|
|
704
720
|
body.side-mid main,body.side-mid footer{margin-left:90px}
|
|
705
|
-
|
|
721
|
+
/* §3.92:<900 左栏收成抽屉时,44px 窄条只做抽屉开关,不再压住居中正文的左缘。
|
|
722
|
+
主区/输入区右移 48px(44 窄条 + 4px 间隙);只加偏移不设宽度,窄屏不会出现横向滚动。
|
|
723
|
+
窄条本身仍是 position:fixed,不参与文档流。 */
|
|
724
|
+
body.side-drawer main,body.side-drawer footer{margin-left:48px}
|
|
706
725
|
/* 折叠态:只留图标 + tooltip(label/分组标题隐藏;折叠按钮本身是图标,保留用于展开) */
|
|
707
726
|
body.side-collapsed .sn-title,
|
|
708
727
|
body.side-mid .sn-title,
|
|
@@ -723,6 +742,7 @@ body.side-mid #sideNavWsIcon{display:inline-flex}
|
|
|
723
742
|
body.side-collapsed .sn-head,body.side-mid .sn-head{padding:10px 6px;justify-content:center}
|
|
724
743
|
body.side-collapsed #sideNav .sn-body,body.side-mid #sideNav .sn-body{padding:10px 6px;align-items:center}
|
|
725
744
|
body.side-collapsed #sideNavNew,body.side-mid #sideNavNew{width:36px;padding:8px 0;gap:0}
|
|
745
|
+
body.side-collapsed .sn-sec-row,body.side-mid .sn-sec-row{justify-content:center}
|
|
726
746
|
body.side-collapsed #sideNavSessions .sn-item,body.side-mid #sideNavSessions .sn-item{width:36px;justify-content:center;padding:6px 0;gap:0}
|
|
727
747
|
body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions .sn-item.on{box-shadow:inset 0 0 0 1px var(--accent)}
|
|
728
748
|
</style>
|
|
@@ -743,21 +763,23 @@ body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions
|
|
|
743
763
|
<div class="spacer"></div>
|
|
744
764
|
<span class="badge badge-btn" id="costBadge" title="省钱仪表盘(点击展开)" style="cursor:pointer">📊 —</span>
|
|
745
765
|
<input id="sessionSearch" placeholder="搜索会话…" title="关键词检索历史会话">
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
766
|
+
<!-- v0.6.27 §3.94:「历史会话」与左栏会话列表重复——保留 hidden 兼容节点(§158 仍钉顶栏入口),
|
|
767
|
+
计划下一轮连同断言一起移除。切换仍走 #sessions 的 change(左栏点击也写回这里)。 -->
|
|
768
|
+
<select id="sessions" title="历史会话" hidden><option value="">历史会话</option></select>
|
|
769
|
+
<button id="tasksBtn" title="任务面板">任务</button>
|
|
770
|
+
<!-- v0.6.27 §3.94:会话管理入口移入左栏「最近会话」标题右侧(#sideNavSessMgr,复用 openSessModal);
|
|
771
|
+
原 #sessMgrBtn 只保留 hidden 兼容节点(§153/§155/§158 仍钉其在 header)。 -->
|
|
772
|
+
<button id="sessMgrBtn" title="管理会话(重命名/删除)" hidden>✎</button>
|
|
773
|
+
<!-- v0.6.27 §3.94:#newChatCompat 包裹层已删除;#newChat 直接以 hidden 保留,
|
|
774
|
+
左侧 #sideNavNew 与 ⌘K 继续复用同一个 handler(§153/§155/§158 仍钉其 id)。 -->
|
|
775
|
+
<button id="newChat" hidden>新对话</button>
|
|
751
776
|
</div>
|
|
752
777
|
</header>
|
|
753
778
|
<div id="trajRail">
|
|
754
779
|
<!-- §3.88:<900 时左栏收成抽屉,这个窄条图标负责打开/关闭(≥900 隐藏)。 -->
|
|
755
780
|
<button class="rail-btn" id="sideNavRailBtn" title="会话侧栏(打开/关闭)">☰</button>
|
|
756
781
|
</div>
|
|
757
|
-
<!-- v0.6.
|
|
758
|
-
右栏「轨迹」「子Agent」标签可达。下面两个隐藏兼容节点不渲染、不绑定点击,仅让既有
|
|
759
|
-
class 清理与历史回归脚本保持空引用安全(#newChatCompat 同理)。 -->
|
|
760
|
-
<span id="legacyRailCompat" hidden aria-hidden="true"><span id="trajRailBtn"></span><span id="subRailBtn"></span></span>
|
|
782
|
+
<!-- v0.6.27 §3.94:#legacyRailCompat 隐藏兼容节点已删除;轨迹/子Agent 由右栏四标签唯一承接。 -->
|
|
761
783
|
<!-- v0.6.24 §3.88 左侧栏:设计稿三栏的会话列。只新增;顶栏既有入口一个不删。
|
|
762
784
|
数据复用 refreshSessions() 的 sessionsGroupedCache;切换走 #sessions change;
|
|
763
785
|
工作空间镜像 #wsSel(原控件保留在顶栏原位);新建对话复用 #newChat handler。 -->
|
|
@@ -773,9 +795,13 @@ body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions
|
|
|
773
795
|
<select id="sideNavWs" title="工作空间(与顶栏 #wsSel 同源,切换走同一个 handler)"></select>
|
|
774
796
|
</div>
|
|
775
797
|
<button id="sideNavWsIcon" type="button" title="工作空间" aria-label="工作空间">📁</button>
|
|
776
|
-
<
|
|
777
|
-
<
|
|
778
|
-
|
|
798
|
+
<div class="sn-sec-row">
|
|
799
|
+
<button id="sideNavRecentToggle" type="button" class="sn-sec-title" aria-expanded="true" aria-controls="sideNavSessions" title="折叠最近会话">
|
|
800
|
+
<span class="sn-sec-chev" aria-hidden="true">▾</span><span class="sn-sec-label">最近会话</span><span class="sn-sec-count" id="sideNavRecentCount" title="当前会话数">0</span>
|
|
801
|
+
</button>
|
|
802
|
+
<!-- v0.6.27 §3.94:会话管理(重命名/删除)入口,从顶栏移入「最近会话」标题右侧,复用 openSessModal。 -->
|
|
803
|
+
<button id="sideNavSessMgr" type="button" title="管理会话(重命名/删除)" aria-label="管理会话">✎</button>
|
|
804
|
+
</div>
|
|
779
805
|
<div id="sideNavSessions" role="list" aria-label="会话列表"></div>
|
|
780
806
|
</div>
|
|
781
807
|
</aside>
|
|
@@ -796,7 +822,7 @@ body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions
|
|
|
796
822
|
</div>
|
|
797
823
|
</div>
|
|
798
824
|
<div id="chat"></div>
|
|
799
|
-
<!-- §3.73:窄屏(<
|
|
825
|
+
<!-- §3.73:窄屏(<1100)右栏收起时,轨迹/子Agent 落在对话流末尾的这一个可折叠区块里,
|
|
800
826
|
不塞回每条消息之间。宽屏隐藏(详情在 #runPanel)。 -->
|
|
801
827
|
<section id="traceDock" aria-label="轨迹与子 Agent" hidden>
|
|
802
828
|
<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 +873,8 @@ body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions
|
|
|
847
873
|
</footer>
|
|
848
874
|
|
|
849
875
|
<!-- P1-4 右栏「本轮运行」(v0.6.19 §3.70):与底部状态条**同源**(runPanelView → runStripView → runState),
|
|
850
|
-
≥
|
|
876
|
+
≥1100 默认展开并推开正文(1101–1180 收窄到 260px);<1100 收起为顶栏「运行」chip,
|
|
877
|
+
点开是覆盖式抽屉。无新增轮询。 -->
|
|
851
878
|
<aside id="runPanel" aria-label="本轮运行" hidden>
|
|
852
879
|
<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
880
|
<!-- §3.82:空结构占位(hidden)。真实的「会话配置 + 本轮数据」按 §3.90 收进下方可折叠
|