mingdao-harness 0.6.17 → 0.6.18

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.
@@ -6,7 +6,7 @@
6
6
 
7
7
  > 自动更新包的**来源签名**是**另一件事**(防"更新源被投毒",不是防 SmartScreen),见 §五:
8
8
  > 代码已就位且判据 fail-closed,但**发布链路尚未接入**——默认档为 `warn-and-install`
9
- > (照旧安装,但 UI/日志明确写出"本次更新未验证来源签名")。
9
+ > (照旧安装,弹窗只给两行「未验证来源签名 + 怎么变严」,完整说明写进日志)。
10
10
 
11
11
  ---
12
12
 
@@ -102,9 +102,12 @@ macOS 作业的 `--mac dmg` 在检测到 `CSC_LINK` + Apple 凭证时自动完
102
102
  >
103
103
  > **本轮状态(诚实标注)**:代码已就位并 fail-closed,但**发布链路尚未接入签名**——
104
104
  > `desktop/gen-update-yml.mjs` 与服务器发布脚本都没有签名步骤,feed 里**没有** `.sig` 文件。
105
- > 因此客户端当前实际走的是**默认档 `warn-and-install`**:照旧安装,但在日志与 UI 里明确写出
106
- > 「**本次更新未验证来源签名**」。要让"没签名就拒绝"生效,发行方需要:① 按下面步骤接入签名;
107
- > ② 在客户端设 `MINGDAO_REQUIRE_UPDATE_SIGNATURE=1`(或改成默认值)。
105
+ > 因此客户端当前实际走的是**默认档 `warn-and-install`**:照旧安装,弹窗给两行——
106
+ > 「本次更新未验证来源签名(发布链路尚未接入签名)——已按默认策略安装。」+
107
+ > 「高安全场景可设 `MINGDAO_REQUIRE_UPDATE_SIGNATURE=1` 拒绝无签名更新;详情见日志与官网。」
108
+ > 完整解释(为什么无法用密码学证明来源、feed 被投毒时无法区分、不改配置与会话)写在**日志**里:
109
+ > 桌面版 `<userData>/logs/mingdao.log` 的 `更新来源校验:…` 行。要让"没签名就拒绝"生效,发行方需要:
110
+ > ① 按下面步骤接入签名;② 在客户端设 `MINGDAO_REQUIRE_UPDATE_SIGNATURE=1`(或改成默认值)。
108
111
 
109
112
  ### 5.1 为什么不是"再算一次 sha512"
110
113
 
@@ -154,7 +157,7 @@ node scripts/update-sign.mjs --verify desktop/dist/mingdao-setup-<ver>-x64.exe -
154
157
  | --- | --- | --- |
155
158
  | 有签名 + 验签通过 | `install` | 提示"来源签名校验通过(ed25519 · keyId=…)",正常安装 |
156
159
  | **有签名 + 验签失败 / 拿不到证据** | `reject` | **一律拒绝**:UI"更新包来源不可信,已拒绝"+ 引导官网手动下载;并**关闭 `autoInstallOnAppQuit`**(否则退出应用时照样装) |
157
- | 无签名 + 默认 | `warn-and-install` | 照旧安装,但日志与 UI 都写明"**本次更新未验证来源签名**"、并说明当前发布链路没有签名步骤 |
160
+ | 无签名 + 默认 | `warn-and-install` | 照旧安装;弹窗**两行**("未验证来源签名…已按默认策略安装" + "可设 `REQUIRE=1` 拒绝;详情见日志与官网"),完整解释写进日志行 |
158
161
  | 无签名 + `MINGDAO_REQUIRE_UPDATE_SIGNATURE=1` | `reject` | fail-closed:发行方/高安全用户的开关 |
159
162
 
160
163
  **为什么不默认 reject**:当前发布链路还没有签名,默认 reject 等于一夜之间掐死所有存量用户的自动更新。
package/docs/CONFIG.md CHANGED
@@ -552,6 +552,11 @@ API id,不参与 `modelLabels`(其条目仍带 `id`,等于自定义模型
552
552
  模型兜底 **32k**、远程兜底 **128k**。
553
553
  - **安全预算**:`contextBudget` 会被自动收紧到 `min(contextBudget, 窗口×75%, 窗口−maxOutput−余量)`,
554
554
  prompt 永不逼近窗口边缘(75% 舒适区以上 prefill 时间陡增)。
555
+ - **按引擎自述 `n_ctx` clamp(v0.6.18 §3.74)**:预检(`GET /props`)读到引擎真实 `n_ctx` 后,
556
+ 若预算超过 `n_ctx × 0.8`,**发请求前**就 clamp 到该上限并打印
557
+ `⚠ 引擎自述上下文 32768:config.contextBudget 131072 超过其 80%(26214),发请求前已自动 clamp …`。
558
+ 这是为了避免"明知引擎装不下还照发"——那种请求只会在 prefill 阶段被 `insufficient memory` 拒绝。
559
+ 想真正用更大上下文,必须调引擎侧 `--ctx-size`,改 `config.contextBudget` 只会被 clamp。
555
560
  - **边缘检测**:模型每轮上报真实 `prompt_tokens`,≥ 窗口 85% 时下一轮强制压缩历史(即使启发式
556
561
  计数低估也强制触发)。
557
562
  - **工具输出截断**:单条工具结果按 `窗口/16` 封顶(最少 2000 字),小窗口不再整条回灌大段代码。
@@ -729,6 +734,37 @@ request failed.` 同一错误此前在 60091 的 35B 上也出现过 → **不
729
734
  3. 临时把 `localConcurrency` 保持 `1`,并减少子代理数量(读只读子代理在本地模型下已不并行);
730
735
  4. **不要**因为 `engine shed its caches and stays up` 去重启引擎——它没有崩;重启只会重新加载模型。
731
736
 
737
+ #### 内存/显存不足时的自动降级重试(v0.6.18 §3.74)
738
+
739
+ 即使有串行化,本地引擎仍可能因**子代理并发 / 长解码 / 其他进程占显存**在 prefill 时拒绝请求:
740
+
741
+ ```
742
+ insufficient memory: the request exceeded available GPU memory
743
+ (sustained critical memory pressure during prefill; aborted before the allocator wall).
744
+ The engine shed its caches and stays up; this request failed.
745
+ ```
746
+
747
+ 真机(v0.6.17,8081 qwen)当时上下文只有 ≈2,773 tokens、预算 32,768——**整回合却直接失败**。
748
+ 现在对**本地端点**(`isLocalBaseUrl` 为真,或 `customModels.<名>.local: true`)改为**一次**有界降级重试:
749
+
750
+ | 动作 | 数值 |
751
+ | --- | --- |
752
+ | 压缩上下文预算 | 本回合 `contextBudget × 0.6`,**下限 4096**(例:32,768 → ~19,660) |
753
+ | 降低输出上限 | 本回合 `maxOutputTokens × 0.5`(例:8,192 → 4,096) |
754
+ | 串行化加强 | 该重试传 `localConcurrency: 1`(复用既有的 `acquireLocalSlot`),即便你配了 0/2 |
755
+ | 重试次数 | **最多一次**;第二次仍失败即如实报错,绝不无限重试 |
756
+
757
+ - **可见性**:界面与日志出现
758
+ `♻ 因本地引擎内存不足,已压缩上下文(32,768 → ~19,660)…并重试一次`;
759
+ 回合成功时 `done.note` 追加压缩前后数值、重试次数与最终结果(`↩ 内存降级重试(1 次)…重试后成功`)。
760
+ - **第二次仍失败**:回合失败,错误文案带压缩前后数值与具体建议——把 `config.contextBudget` 调到多少、
761
+ 关闭其他占用显存的模型进程/并行会话、引擎侧调大 `--ctx-size` 或使用 q8 KV 量化。
762
+ - **云端端点不重试**:云 API 的 507/容量问题不是本地上下文能改的,压缩重试只会多烧一次请求;
763
+ 400/401/429 等非内存错误同样不触发该恢复。
764
+ - **判据单一来源**:`isMemoryPressureError`(协议层:507 / `insufficient memory` / `memory_refusal` /
765
+ `allocator wall` / `engine shed its caches and stays up` / OOM 措辞)+
766
+ `describeUpstreamError(err, { isLocal })` 的 `recoverableByShrinking` 才决定是否重试。
767
+
732
768
  ### 用本地模型测 harness(测试者步骤清单)
733
769
 
734
770
  > 这一段是**操作清单**,不是原理说明。按顺序做,能在几分钟内区分"端点不行"和"harness 不行"。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.17",
3
+ "version": "0.6.18",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -8,7 +8,9 @@ import { compactConversation } from './compact.js';
8
8
  // 两个对象从此分开:内核 ctx(makeCtx() 的产物)只在循环内用;工具只拿到 makeToolCtx() 的白名单对象。
9
9
  import { buildToolSchemas, dispatch, makeToolCtx } from './tools/index.js';
10
10
  import { DEFAULT_MODEL, modelPreset } from './models.js';
11
- import { resolveModelCaps, safeBudget, EDGE_RATIO } from './model-caps.js';
11
+ import { resolveModelCaps, safeBudget, EDGE_RATIO, isLocalBaseUrl } from './model-caps.js';
12
+ // v0.6.18 §3.74:预检已读到引擎自述 n_ctx 时,**发请求前**按 n_ctx×0.8 clamp 预算(纯函数)。
13
+ import { clampContextBudgetToEngine } from './model-discovery.js';
12
14
  import { makeTokenCounter } from './tokenizer.js';
13
15
  import { createHooks } from './hooks.js';
14
16
  import { createIO, style, C } from './ui.js';
@@ -18,7 +20,7 @@ import { redactSecrets } from './redact.js';
18
20
  import { checkCostGuard, costGuardConfig, todayCost, preflightBlockMessage, roundGuardAction } from './cost-guard.js';
19
21
  import { recordCacheStats, packDailyCost } from './cachestats.js';
20
22
  import { estimateCost, cacheSplit, isPeakHour } from './pricing.js';
21
- import { resolveProviderConfig, createProvider, localGateSnapshot, localConcurrencyLimit } from './providers/index.js';
23
+ import { resolveProviderConfig, createProvider, localGateSnapshot, localConcurrencyLimit, isMemoryPressureError } from './providers/index.js';
22
24
  import { compileConstraints, checkPreTool, checkPostTool, checkOutput, blockedOutputText } from './constraints.js';
23
25
  import { createLedger, newRunId } from './ledger.js';
24
26
  import { registerEgressSink } from './net-guard.js';
@@ -77,6 +79,13 @@ export const DEFAULT_NO_PROGRESS_TIMEOUT_MS = 3600000;
77
79
  * 形态下多等 15s(相对默认 60 分钟是 0.4%),换"子代理自己的结论能回传父回合"。 */
78
80
  export const SUBAGENT_WAIT_GRACE_MS = 15000;
79
81
 
82
+ /** 本地引擎内存不足时允许的**降级重试次数**(v0.6.18 §3.74)。
83
+ *
84
+ * 只允许一次:内存墙不是网络抖动——同一份大 prompt 重发只会再撞一次。第一次失败后
85
+ * agent 会压缩上下文 + 输出减半 + 强制串行;第二次仍失败就如实报错,绝不无限重试。
86
+ * 抽成常量是为了让"最多一次"可被断言/变异钉住(不是散在 if 里的魔法值)。 */
87
+ export const MAX_MEMORY_RETRIES = 1;
88
+
80
89
  /** 解析无进展阈值:非正/非有限一律回落默认(fail-safe,防"配置写 0 就静默失去看门狗")。 */
81
90
  export function resolveNoProgressTimeoutMs(/** @type {any} */ cfg) {
82
91
  const v = Number(cfg?.noProgressTimeoutMs);
@@ -281,13 +290,20 @@ export function zeroToolCallNotice({ modelRounds, steps = 0, taskLike = false })
281
290
  /**
282
291
  * 上游/引擎错误的可操作解读(纯函数):把「HTTP 400 + 一段 Jinja/模板报错」这类原文
283
292
  * 变成「原文 + 这是什么 + 该怎么办」。识别不出时 hint 为 null(**绝不改原文**)。
293
+ *
294
+ * v0.6.18 §3.74:额外返回 `recoverableByShrinking`——**只有本地端点**的内存/显存不足
295
+ * 才是"压缩上下文 + 降输出 + 串行重试一次"能救的;云端 API 的 507/内存报错不在此列
296
+ * (云端的容量/配额不是本地上下文能改的,对云 API 做压缩重试只会多烧一次请求)。
284
297
  * @param {any} err
285
- * @returns {{ message: string, hint: string|null, status: number|null }}
298
+ * @param {{ isLocal?: boolean }} [opts]
299
+ * @returns {{ message: string, hint: string|null, status: number|null, recoverableByShrinking: boolean }}
286
300
  */
287
- export function describeUpstreamError(/** @type {any} */ err) {
301
+ export function describeUpstreamError(/** @type {any} */ err, opts = {}) {
288
302
  const raw = String(err?.message ?? err ?? '').trim();
289
303
  const status = Number(err?.status) > 0 ? Number(err.status) : null;
290
304
  const t = raw.toLowerCase();
305
+ const memoryPressure = isMemoryPressureError(err);
306
+ const recoverableByShrinking = Boolean(memoryPressure && opts?.isLocal === true);
291
307
  /** @type {string|null} */
292
308
  let hint = null;
293
309
  if (/jinja|template|no user query|parser for this template/.test(t)) {
@@ -321,7 +337,7 @@ export function describeUpstreamError(/** @type {any} */ err) {
321
337
  }
322
338
  // 上游原文可能很长(内核只保留前 400 字):如实标注它被截断过,避免用户以为这就是全部
323
339
  const truncated = /…|\.\.\.$/.test(raw) || raw.length >= 380;
324
- return { message: raw, hint: hint ? `💡 ${hint}${truncated ? '(上面的上游原文可能被截断)' : ''}` : null, status };
340
+ return { message: raw, hint: hint ? `💡 ${hint}${truncated ? '(上面的上游原文可能被截断)' : ''}` : null, status, recoverableByShrinking };
325
341
  }
326
342
 
327
343
  /**
@@ -436,7 +452,17 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
436
452
  const caps = resolveModelCaps(cfg, modelName);
437
453
  // safeBudget 恒用:即使用户显式 contextBudget 也套「窗口−输出−余量」上限与 75% 舒适区,
438
454
  // 防显式值撑爆窗口(本地模型窗口可能只有 32k/131k,用户却留了默认 128000)。
439
- const budget = safeBudget(cfg, caps);
455
+ const safeB = safeBudget(cfg, caps);
456
+ // v0.6.18 §3.74(自适应上下文):预检若已读到**引擎自述 n_ctx**,配置预算再大也没用——
457
+ // 发出去就会在 prefill 阶段被引擎以 insufficient memory 拒绝。这里在**创建 agent 时**
458
+ // (即第一次请求之前)就 clamp 到 n_ctx×0.8,并在第一回合开始前打印一条明确提示。
459
+ const budgetClamp = clampContextBudgetToEngine({
460
+ contextBudget: safeB,
461
+ configuredBudget: Number(cfg?.contextBudget) > 0 ? Number(cfg.contextBudget) : null,
462
+ engineCtx: cfg?.endpointProbe?.engineCtx ?? null,
463
+ });
464
+ const budget = budgetClamp.budget ?? safeB;
465
+ const budgetClampNotice = budgetClamp.clamped ? budgetClamp.notice : '';
440
466
  // maxOutput 也按窗口封顶:显式配超大 maxOutputTokens 时,prompt(预算)+output 仍不得越过窗口
441
467
  // (预算已按 caps.maxOutputTokens 留余量,但显式值可能更大——此处兜底,防服务端截断/拒绝)
442
468
  const maxOutput = Math.min(
@@ -480,6 +506,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
480
506
  // (CLI/REPL 每次派子代理都漏计;README 主推的「多方向并行调研」场景漏计最重)。
481
507
  // runTurn 开始时指向本轮 usage,finally 清空;runTurn 之外调用 spawnTask 时为 null(安全跳过)。
482
508
  let currentUsage = /** @type {any} */ (null);
509
+ // v0.6.18 §3.74:本回合的「内存降级重试」记录(runTurn 内写、runTurn 包装层读)与本回合
510
+ // 必须进 done.note 的提示(clamp 提示 / 压缩重试提示)。声明在 runTurn 之外,包装层才能把
511
+ // 这些提示统一追加到**每一条** return 路径上(runTurn 内部有十几个 return,逐条补会漏)。
512
+ let lastMemoryRecovery = /** @type {any} */ (null);
513
+ let turnNotes = /** @type {string[]} */ ([]);
514
+ /** clamp 提示每个 agent 只打一次(子代理各自持有一份,但只在各自第一回合出现)。 */
515
+ let clampNoticeShown = false;
483
516
  // v0.6.0 C1:当前回合的账本写入器(与 currentUsage 同款:runTurn 内赋值、finally 清空)。
484
517
  // 账本不可用/被关闭时它是 no-op,**绝不影响主流程**——与 writeAudit 同款容错。
485
518
  let turnLedger = /** @type {any} */ (null);
@@ -746,9 +779,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
746
779
  };
747
780
  }
748
781
 
749
- async function runTurn(/** @type {any} */ messages) {
782
+ async function runTurnInner(/** @type {any} */ messages) {
750
783
  let steps = 0;
751
784
  let finish = null;
785
+ // v0.6.18 §3.74:本回合实际生效的 prompt 预算 / 输出上限——内存不足时**只在本回合内**
786
+ // 压小(contextBudget ×0.6、maxOutputTokens ×0.5),不写回用户配置。
787
+ let turnBudget = budget;
788
+ let turnMaxOutput = maxOutput;
789
+ /** 本回合已用掉的内存降级重试次数(上限 MAX_MEMORY_RETRIES,默认 1)。 */
790
+ let memoryRetries = 0;
791
+ lastMemoryRecovery = null;
752
792
  /** v0.6.3(M-16):本回合是否出现过"上游提前关流"(回答可能不完整)。
753
793
  * 刻意不叫 truncated——既有 `truncated` 的含义是"步数兜底总结也失败了",两者必须分开。 */
754
794
  let upstreamTruncated = false;
@@ -1163,7 +1203,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1163
1203
  if (turnProgress.phase !== '已中止(无进展)') turnProgress.phase = '压缩上下文';
1164
1204
  const compacted = await compactConversation({
1165
1205
  messages,
1166
- budget,
1206
+ budget: turnBudget,
1167
1207
  count,
1168
1208
  provider,
1169
1209
  executorModel: subagentModel(cfg, modelName),
@@ -1203,7 +1243,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1203
1243
  }
1204
1244
  } catch {}
1205
1245
  }
1206
- const trimmed = trimMessages(messages, budget, count);
1246
+ const trimmed = trimMessages(messages, turnBudget, count);
1207
1247
 
1208
1248
  // reasoning 回填防护(Kimi P0 修正):带 tool_calls 的 assistant 消息必须「完整」回传
1209
1249
  // reasoning_content(DeepSeek thinking_mode 官方要求:后续请求含 tools 时缺/截断即 400,
@@ -1239,7 +1279,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1239
1279
  if (g && Number(g.dailyLimitYuan) > 0 && g.action !== 'downgrade') {
1240
1280
  let promptTokens = 0;
1241
1281
  for (const m of sanitized) promptTokens += messageTokens(m, count);
1242
- const worst = estimateCost(activeModel, promptTokens, maxOutput, null, new Date());
1282
+ const worst = estimateCost(activeModel, promptTokens, turnMaxOutput, null, new Date());
1243
1283
  // 判据本身抽到 cost-guard.js 的 preflightBlockMessage(v0.6.11 / P1-1 第一刀):
1244
1284
  // 它能被单测逐条钉边界,而 runTurn 只负责"拿到数字 → 问判据 → 该拦就拦"。
1245
1285
  // 统计不可读 / 无价格数据时判据返回 null(不误拦、也不静默放行——主检查会告警)。
@@ -1332,7 +1372,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1332
1372
  contextWindow: caps.contextWindow,
1333
1373
  isLocal: caps.isLocal,
1334
1374
  firstTokenMs: Number(cfg?.timeout?.firstTokenMs) > 0 ? Number(cfg.timeout.firstTokenMs) : effFirstTokenMs,
1335
- budget,
1375
+ budget: turnBudget,
1336
1376
  probe: cfg?.endpointProbe || null,
1337
1377
  localBusy: Boolean(caps.isLocal && (gate.active > 0 || gate.queued > 0)),
1338
1378
  localActive: gate.active,
@@ -1358,9 +1398,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1358
1398
  messages: sanitized,
1359
1399
  tools: toolsFor(readOnlyPhase, turnStrippedSet),
1360
1400
  temperature,
1361
- maxTokens: maxOutput,
1401
+ maxTokens: turnMaxOutput,
1362
1402
  // MiniMax P0:仅当 activeModel 声明支持 reasoning 才发送(否则 400 终止回合);'off' 显式禁用例外
1363
1403
  reasoningEffort: modelPreset(activeModel)?.supportsReasoning || reasoningEffort === 'off' ? reasoningEffort : undefined,
1404
+ // v0.6.18 §3.74:发生过内存降级后,本回合后续请求**一律压回单并发**(即便用户把
1405
+ // config.localConcurrency 配成 0/2)——provider 会把它传给 acquireLocalSlot。
1406
+ ...(memoryRetries > 0 ? { localConcurrency: 1 } : {}),
1364
1407
  signal: ac.signal,
1365
1408
  // v0.6.15(§3.59 第 1 条):本地端点串行化必须**在界面上可见**——
1366
1409
  // 排队时状态条/进度行写"排队等待本地端点(已串行化防内存压力)",
@@ -1440,6 +1483,68 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1440
1483
  // MacBook 本地 507 memory_refusal 根因(v0.4.5):服务端内存拒绝不是「空输出」——
1441
1484
  // 直接终结合合并透出降级提示,不计入空轮、不注入续写重试(内存未释放必再 507,空烧请求)。
1442
1485
  const e = /** @type {any} */ (err);
1486
+ // ---------------------------------------------------------------------------
1487
+ // v0.6.18 §3.74:**本地引擎内存/显存不足 → 压缩上下文 + 降输出 + 强制串行,重试一次**
1488
+ //
1489
+ // 真机(v0.6.17,8081 qwen):子代理并发/长解码导致显存峰值超限,引擎在 prefill 前拒绝,
1490
+ // 整回合直接失败;而当时上下文只有 ≈2,773 tokens、预算 32,768——压小预算/输出上限后
1491
+ // 极可能成功。这里不再立刻判失败,而是**只对本地端点**做一次有界降级重试:
1492
+ // · contextBudget ×0.6(下限 4k,避免压到连系统提示都放不下);
1493
+ // · maxOutputTokens ×0.5(只用于本回合后续请求);
1494
+ // · 该次重试传 localConcurrency=1(provider 的 acquireLocalSlot 会把并发上限压回 1);
1495
+ // · 只重试一次:memoryRetryUsed 一旦置真,第二次失败就如实抛错(不无限重试)。
1496
+ // 云端端点不在此列:云 API 的 507/容量问题不是本地上下文能改的,压缩重试只会多烧一次请求。
1497
+ // ---------------------------------------------------------------------------
1498
+ let localEndpoint = false;
1499
+ try {
1500
+ // 用**本回合实际使用**的模型判本地:费用护栏可能把 activeModel 降级到云端模型,
1501
+ // 此时不能拿原始 modelName 的 caps.isLocal 误判成"本地内存不足"。
1502
+ const activeCaps = resolveModelCaps(cfg, activeModel);
1503
+ const pcNow = resolveProviderConfig(cfg, activeModel);
1504
+ localEndpoint = Boolean(activeCaps.isLocal || isLocalBaseUrl(pcNow?.baseUrl || provider?.config?.baseUrl || ''));
1505
+ } catch {}
1506
+ const localMemory = describeUpstreamError(e, { isLocal: localEndpoint });
1507
+ if (localMemory.recoverableByShrinking) {
1508
+ if (memoryRetries >= MAX_MEMORY_RETRIES) {
1509
+ // 第二次仍失败:不掩盖,把压缩前后数值、重试次数、具体建议一起抛给用户/日志。
1510
+ const rec = /** @type {any} */ (lastMemoryRecovery) || {};
1511
+ const suggest = Math.max(4096, Math.floor(Number(rec.toBudget) > 0 ? Number(rec.toBudget) * 0.7 : 8192));
1512
+ const detail =
1513
+ `本地引擎内存不足:已压缩上下文(${fmtTok(rec.fromBudget)} → ~${fmtTok(rec.toBudget)})、` +
1514
+ `输出上限 ${fmtTok(rec.fromMaxOutput)} → ${fmtTok(rec.toMaxOutput)},并重试 1 次(最多一次),仍失败——不再重试。` +
1515
+ `建议:把 config.contextBudget 调到 ${fmtTok(suggest)} 左右(或更低),关闭其他占用显存的模型进程/并行会话,` +
1516
+ `并在引擎侧调大 --ctx-size 或使用 q8 KV 量化。`;
1517
+ try { io.print(style(`⚠ ${localMemory.message}\n ${detail}`, C.yellow)); } catch {}
1518
+ const enriched = new Error(`${localMemory.message}\n ${localMemory.hint || ''}\n ↩ ${detail}`);
1519
+ /** @type {any} */ (enriched).status = e?.status ?? 507;
1520
+ if (e?.headers !== undefined) /** @type {any} */ (enriched).headers = e.headers;
1521
+ throw enriched;
1522
+ }
1523
+ memoryRetries += 1;
1524
+ const fromBudget = turnBudget;
1525
+ const fromMaxOutput = turnMaxOutput;
1526
+ // 压缩比例 0.6、下限 4096:0.6 在真机 32,768 → ~19,660 的区间内足够把 KV cache 峰值压下来;
1527
+ // 再低会伤任务质量,且 4k 以下连系统提示 + 一轮工具结果都放不下(重启永远失败)。
1528
+ turnBudget = Math.max(4096, Math.floor(fromBudget * 0.6));
1529
+ turnMaxOutput = Math.max(256, Math.floor(fromMaxOutput * 0.5));
1530
+ lastMemoryRecovery = {
1531
+ attempts: memoryRetries,
1532
+ fromBudget,
1533
+ toBudget: turnBudget,
1534
+ fromMaxOutput,
1535
+ toMaxOutput: turnMaxOutput,
1536
+ at: Date.now(),
1537
+ };
1538
+ const msg =
1539
+ `♻ 因本地引擎内存不足,已压缩上下文(${fmtTok(fromBudget)} → ~${fmtTok(turnBudget)})、` +
1540
+ `输出上限 ${fmtTok(fromMaxOutput)} → ${fmtTok(turnMaxOutput)},并重试一次(串行化:并发上限 1)`;
1541
+ try { io.print(style(msg, C.yellow)); } catch {}
1542
+ turnNotes.push(msg);
1543
+ turnProgress.phase = '内存降级重试(已压缩上下文)';
1544
+ turnProgress.memoryRecovery = lastMemoryRecovery;
1545
+ steps = Math.max(0, steps - 1); // 重试不额外消耗一步,避免 maxSteps=1 时根本没机会重试
1546
+ continue;
1547
+ }
1443
1548
  // v0.6.15(§3.59 第 2 条):`engine shed its caches and stays up` / `allocator wall`
1444
1549
  // 说明引擎**活着**(自己丢缓存后继续服务)——不走这条"507 旧文案"(那里建议重启引擎,
1445
1550
  // 对活着但内存吃紧的引擎是错误处置);交给下面 describeUpstreamError 的独立分型。
@@ -1552,7 +1657,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1552
1657
  if (steps === stepLimit) {
1553
1658
  io.endTurn();
1554
1659
  if (res.text) {
1555
- const ap = await applyOutputConstraints(res.text, { messages, provider, activeModel, temperature, maxOutput, usage, signal: currentAc?.signal });
1660
+ const ap = await applyOutputConstraints(res.text, { messages, provider, activeModel, temperature, maxOutput: turnMaxOutput, usage, signal: currentAc?.signal });
1556
1661
  messages.push({ role: 'assistant', content: ap.text });
1557
1662
  // 步数已耗尽:这一条与下方 `break`(→ 兜底总结/截断收尾)必须是同一口径
1558
1663
  markOutcome('capped', { capHit: true });
@@ -1907,7 +2012,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1907
2012
  if (res.text) {
1908
2013
  // v0.5.0 A3 ③:输出前领域约束必须在**回填历史之前**应用——
1909
2014
  // 否则被拦下的违规措辞会留在会话里,下一轮又被当作既成事实喂回模型。
1910
- const ap = await applyOutputConstraints(res.text, { messages, provider, activeModel, temperature, maxOutput, usage, signal: currentAc?.signal });
2015
+ const ap = await applyOutputConstraints(res.text, { messages, provider, activeModel, temperature, maxOutput: turnMaxOutput, usage, signal: currentAc?.signal });
1911
2016
  finalText = ap.text;
1912
2017
  outputNote = ap.note;
1913
2018
  messages.push({ role: 'assistant', content: finalText });
@@ -2080,7 +2185,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2080
2185
  messages: wrapReq,
2081
2186
  tools: [],
2082
2187
  temperature,
2083
- maxTokens: Math.min(maxOutput, 2048),
2188
+ maxTokens: Math.min(turnMaxOutput, 2048),
2084
2189
  reasoningEffort: modelPreset(activeModel)?.supportsReasoning ? 'low' : undefined,
2085
2190
  signal: currentAc.signal,
2086
2191
  onDelta(/** @type {any} */ d) {
@@ -2094,7 +2199,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2094
2199
  }
2095
2200
  // v0.5.0 A3 ③:兜底总结同样过一遍输出约束
2096
2201
  const wrapApplied = wrapRes.text
2097
- ? await applyOutputConstraints(wrapRes.text, { messages, provider, activeModel, temperature, maxOutput, usage, signal: currentAc?.signal })
2202
+ ? await applyOutputConstraints(wrapRes.text, { messages, provider, activeModel, temperature, maxOutput: turnMaxOutput, usage, signal: currentAc?.signal })
2098
2203
  : { text: null, note: null };
2099
2204
  if (wrapApplied.text) messages.push({ role: 'assistant', content: wrapApplied.text });
2100
2205
  // capHit:本轮因步数上限被迫收尾(任务可能未真正完成)→ 供上层落检查点续跑
@@ -2213,6 +2318,38 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2213
2318
  }
2214
2319
  }
2215
2320
 
2321
+ /**
2322
+ * 回合包装层(v0.6.18 §3.74):把「本回合必须让用户看见」的提示统一追加到 done.note。
2323
+ *
2324
+ * 为什么需要包装:`runTurnInner` 有十几个 return 路径(工具收尾、cap、stalled、aborted…),
2325
+ * 逐条补 note 必漏。这里只做两件事:
2326
+ * ① 第一回合发请求前打印「按引擎 n_ctx clamp 预算」的提示(只打一次,避免每回合刷屏);
2327
+ * ② 回合结束后把 clamp 提示与内存降级重试的**压缩前后数值 / 重试次数 / 最终结果**
2328
+ * 追加到 `note`——WebUI 的 done.note、CLI 的收尾说明因此共用同一份口径。
2329
+ */
2330
+ async function runTurn(/** @type {any} */ messages) {
2331
+ turnNotes = [];
2332
+ if (budgetClampNotice && !clampNoticeShown) {
2333
+ clampNoticeShown = true;
2334
+ try { io.print(style(budgetClampNotice, C.yellow)); } catch {}
2335
+ turnNotes.push(budgetClampNotice);
2336
+ }
2337
+ const r = await runTurnInner(messages);
2338
+ const notes = turnNotes.filter(Boolean);
2339
+ if (lastMemoryRecovery && r) {
2340
+ const rec = lastMemoryRecovery;
2341
+ const final = r.aborted ? '重试后被中断' : r.stalled ? '重试后无进展中止' : r.text ? '重试后成功' : '重试后仍无正文';
2342
+ notes.push(
2343
+ `↩ 内存降级重试(${Number(rec.attempts) || 1} 次):上下文预算 ${fmtTok(rec.fromBudget)} → ${fmtTok(rec.toBudget)}、` +
2344
+ `输出上限 ${fmtTok(rec.fromMaxOutput)} → ${fmtTok(rec.toMaxOutput)};${final}。`
2345
+ );
2346
+ }
2347
+ if (notes.length && r && typeof r === 'object') {
2348
+ r.note = [r.note, ...notes].filter(Boolean).join('\n');
2349
+ }
2350
+ return r;
2351
+ }
2352
+
2216
2353
  return {
2217
2354
  modelName,
2218
2355
  budget,
@@ -866,6 +866,45 @@ export function engineContextNotice({ engineCtx = null, configuredWindow = null,
866
866
  return `引擎自述上下文 ${eng} vs 配置 ${conf ?? '(未声明)'} —— 配置未超出引擎能力${where}`;
867
867
  }
868
868
 
869
+ // ---------------------------------------------------------------------------
870
+ // v0.6.18 §3.74:**发请求前按引擎自述 n_ctx clamp 预算**
871
+ //
872
+ // 只"提示配置超出引擎能力"还不够:真机(v0.6.17,8081 qwen)配置 contextBudget=32768、
873
+ // 引擎自述 n_ctx 也很小,请求照样发出去,然后整回合被 `insufficient memory` 拒绝。
874
+ // 既然预检已经读到了 n_ctx,就在**发请求之前**把 prompt 预算压到 n_ctx×0.8 以下——
875
+ // 明知会炸还发,等于把一次必然失败的 prefill 再烧一遍。
876
+ //
877
+ // 为什么留 20%:引擎的 n_ctx 是 prompt+输出/KV cache 的总窗口,输出上限、模板开销与
878
+ // 并发请求都要占同一份窗口;贴着 n_ctx 发等于没有余量。0.8 与 PREFILL_WARN_RATIO 同口径。
879
+ // ---------------------------------------------------------------------------
880
+
881
+ /** 引擎自述 n_ctx 的预算占比(超过即 clamp)。 */
882
+ export const ENGINE_BUDGET_RATIO = 0.8;
883
+
884
+ /**
885
+ * 按引擎自述上下文 clamp prompt 预算(纯函数,便于断言/变异)。
886
+ *
887
+ * @param {{ contextBudget?: number|null, configuredBudget?: number|null, engineCtx?: number|null, ratio?: number }} [input]
888
+ * @returns {{ clamped: boolean, budget: number|null, from: number|null, to: number|null,
889
+ * engineCtx: number|null, ceiling: number|null, ratio: number, notice: string }}
890
+ */
891
+ export function clampContextBudgetToEngine({ contextBudget = null, configuredBudget = null, engineCtx = null, ratio = ENGINE_BUDGET_RATIO } = {}) {
892
+ const raw = Number(contextBudget) > 0 ? Math.floor(Number(contextBudget)) : null;
893
+ const confRaw = Number(configuredBudget) > 0 ? Math.floor(Number(configuredBudget)) : raw;
894
+ const eng = Number(engineCtx) > 0 ? Math.floor(Number(engineCtx)) : null;
895
+ const r = Number(ratio) > 0 && Number(ratio) < 1 ? Number(ratio) : ENGINE_BUDGET_RATIO;
896
+ const base = { clamped: false, budget: raw, from: raw, to: raw, engineCtx: eng, ceiling: null, ratio: r, notice: '' };
897
+ if (raw == null || eng == null) return base; // 没配置/没读到 n_ctx → 不猜、不 clamp
898
+ const ceiling = Math.max(1024, Math.floor(eng * r));
899
+ if (raw <= ceiling) return { ...base, ceiling };
900
+ const to = ceiling;
901
+ const notice =
902
+ `⚠ 引擎自述上下文 ${eng}:config.contextBudget ${confRaw} 超过其 ${Math.round(r * 100)}%(${ceiling}),` +
903
+ `发请求前已自动 clamp 上下文预算 ${raw} → ${ceiling}——否则长上下文会在 prefill 阶段被引擎直接拒绝` +
904
+ `(insufficient memory)。如需更大上下文,请在引擎侧调大 --ctx-size,而不是只改 config.contextBudget。`;
905
+ return { clamped: true, budget: to, from: raw, to, engineCtx: eng, ceiling, ratio: r, notice };
906
+ }
907
+
869
908
  export function modelCacheFile() {
870
909
  return path.join(mingdaoHome(), 'model-cache.json');
871
910
  }
@@ -11,7 +11,9 @@
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { pathToFileURL } from 'node:url';
14
- import { chat as openaiChat } from './openai-compatible.js';
14
+ import { chat as openaiChat, isMemoryPressureError } from './openai-compatible.js';
15
+ // 供 agent/model-discovery 与回归套件复用同一条「内存压力」判据(单一来源,避免各层各写一份正则)。
16
+ export { isMemoryPressureError };
15
17
  import { modelPreset, providerPreset } from '../models.js';
16
18
  import { mingdaoHome } from '../config.js';
17
19
  import { resolveApiKey, getStoredKey } from '../credentials.js';
@@ -421,6 +423,11 @@ const sleep = (/** @type {any} */ ms, /** @type {any} */ signal) =>
421
423
  });
422
424
 
423
425
  function isTransient(/** @type {any} */ err) {
426
+ // v0.6.18 §3.74:内存/显存不足**不是瞬态网络抖动**——原样重发同一份大 prompt 只会再撞一次
427
+ // 内存墙(真机 8081:父回合 + 子代理并发 prefill)。这类错误交给 agent 做「压缩上下文 +
428
+ // 输出减半 + 强制串行」的一次性恢复;这里必须**不重试**,否则同回合会白烧 2 次请求
429
+ // (且 500+insufficient memory 会被 `status >= 500` 误判成瞬态)。
430
+ if (isMemoryPressureError(err)) return false;
424
431
  const status = err?.status;
425
432
  if (status === 429 || (status >= 500 && status <= 504)) return true;
426
433
  // 注意:不含 abort —— 用户 Ctrl+C 主动中断不应触发重试
@@ -511,7 +518,9 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
511
518
  // 必须在**首 token 计时之前**排队:排队是内核自己的调度,不是"引擎迟迟不出首帧"。
512
519
  // 云端端点由 acquireLocalSlot 内部直接放行(不排队、不记日志、不加延迟)。
513
520
  releaseLocal = await acquireLocalSlot(pc.baseUrl, {
514
- cfg,
521
+ // v0.6.18 §3.74:内存降级重试时 agent 传 localConcurrency=1,**临时把该端点压回单并发**
522
+ // (即便用户把 config.localConcurrency 配成 0/2)——重试期间不允许父回合/子代理再同时 prefill。
523
+ cfg: opts.localConcurrency != null ? { ...cfg, localConcurrency: opts.localConcurrency } : cfg,
515
524
  signal: ac.signal,
516
525
  onWait: opts.onQueueWait,
517
526
  endpoint: pc.name || pc.baseUrl,
@@ -17,9 +17,36 @@ function extractStreamError(/** @type {any} */ json) {
17
17
  /** @type {ApiError} */
18
18
  const e = new Error(`[流式响应错误${code ? ' ' + code : ''}] ${msg}`);
19
19
  if (code) e.status = code;
20
+ if (isMemoryPressureError(e)) /** @type {any} */ (e).memoryPressure = true;
20
21
  return e;
21
22
  }
22
23
 
24
+ // 本地引擎「内存/显存不足」的判据(纯函数,v0.6.18 §3.74):
25
+ // · HTTP 507(Insufficient Storage,llama.cpp / vLLM 的内存拒绝常用);
26
+ // · 真机原文 `insufficient memory … aborted before the allocator wall` /
27
+ // `The engine shed its caches and stays up` / `memory_refusal`;
28
+ // · 中文本地引擎常见措辞(内存不足 / 内存拒绝 / 显存不足);
29
+ // · 通用 OOM 措辞(CUDA out of memory / OOM)。
30
+ // **只认"内存类"**:400/401/429 与普通 5xx 一律不命中——否则会把重试/压缩用在无意义的场景上。
31
+ // 注意:这是**协议层**判据(有没有可能是内存问题);「能不能压缩重试」还要叠加
32
+ // `isLocalBaseUrl` —— 云 API 的 507 不压缩重试(见 agent.js 的 describeUpstreamError)。
33
+ const MEMORY_PRESSURE_RE =
34
+ /insufficient memory|memory_refusal|allocator wall|engine shed its caches and stays up|out of memory|\boom\b|cuda.*memory|gpu memory|显存(?:不足|不够)|内存(?:不足|拒绝|不够)/i;
35
+
36
+ /**
37
+ * 这次错误是否属于「本地引擎内存/显存不足」(纯函数)。
38
+ * @param {any} err
39
+ * @returns {boolean}
40
+ */
41
+ export function isMemoryPressureError(/** @type {any} */ err) {
42
+ const status = Number(err?.status);
43
+ if (status === 507) return true;
44
+ const text = String(err?.message ?? err ?? '');
45
+ if (!text) return false;
46
+ if (MEMORY_PRESSURE_RE.test(text)) return true;
47
+ return Boolean(err?.memoryPressure);
48
+ }
49
+
23
50
  export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, onActivity, includeUsage = true, responseFormat, reasoningEffort }) {
24
51
  const url = String(baseUrl).replace(/\/+$/, '') + '/chat/completions';
25
52
  const payload = /** @type {Record<string, any>} */ ({ model, messages });
@@ -76,6 +103,7 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
76
103
  const e = new Error(`[${model}] API 错误 ${res.status}: ${detail}${hint}`);
77
104
  e.status = res.status;
78
105
  e.headers = res.headers; // 重试退避读取 Retry-After 用
106
+ if (isMemoryPressureError(e)) /** @type {any} */ (e).memoryPressure = true;
79
107
  throw e;
80
108
  }
81
109
 
package/src/web/app.js CHANGED
@@ -203,7 +203,9 @@ let ctxLimit = 128000; // /api/state.contextBudget(上下文上限)
203
203
  let lastBudgetUsed = 0; // 上一回合 done.budget.used:生成中用"上一轮 used + 本轮字符估算"(诚实标 ≈)
204
204
  // 状态条的悬浮详情:最近一次工具 / 子 Agent / 本地队列 / 权限来源(不占常驻行)
205
205
  if ($('#runStrip')) attachTip($('#runStrip'), () => runState.detail || '运行状态:阶段 · 首帧 · 吞吐 · 上下文 · 工具步数 · 本轮费用');
206
- const sessionSubs=[]; // 本会话全部子代理(task 工具)条目:{seq, question, result, msg}
206
+ const sessionSubs=[]; // 本会话全部子代理(task 工具)条目:{seq, question, result, msg, round, toolCalls, modelRounds, lastProgressAgoMs, status}
207
+ let turnSeq=0; // 本会话的轮次序号(对话流一行摘要 / 右栏轨迹分组的同一键)
208
+ let pendingSubagent=null; // 正在运行的 task 子代理快照(progress.subagent 刷新;任务完成后固化进 sessionSubs)
207
209
  // 回到底部悬浮按钮:滚动容器为 main;上滚超过 300px 时出现,点击平滑回底
208
210
  (function(){
209
211
  const scroller = document.querySelector('main');
@@ -234,16 +236,16 @@ function addReasoning(msg){ const d=document.createElement('details'); d.classNa
234
236
  function addUser(text){ const el=document.createElement('div'); el.className='msg-user'; el.innerHTML='<div class="bubble">'+esc(text)+'</div>'; chatEl.appendChild(el); renderEmptyState(); scrollBottom(); }
235
237
 
236
238
  const runningTools = new Map();
237
- // P1-1 工具时间线(v0.6.19 §3.70):同一轮所有工具调用聚合成**一张**卡,节点复用不跳位。
238
- // 开始/完成只更新同一行(不再"删掉旧卡、在消息末尾重建一张"),结论顺序稳定;失败行红边高亮。
239
+ // P1-1 工具时间线(v0.6.19 §3.70)保留为轨迹详情的渲染原语(toolStepNode/paintToolStep/
240
+ // toolTimelineSummary);§3.73 起对话流不再挂载内联卡——每轮只留一行摘要,详情进右栏/末尾区块。
239
241
  function renderToolStartEvent(ev){
240
- timelineToolStart(activeAiMsg, ev);
242
+ traceToolStarted(activeAiMsg, ev);
241
243
  }
242
244
  function renderToolEvent(ev){
243
- timelineToolDone(activeAiMsg, ev);
245
+ traceToolFinished(activeAiMsg, ev);
244
246
  }
245
247
  function renderToolDeniedEvent(ev){
246
- timelineToolDenied(activeAiMsg, ev);
248
+ traceToolDenied(activeAiMsg, ev);
247
249
  }
248
250
  function renderTodos(todos,msg){
249
251
  const ul=document.createElement('ul'); ul.className='todo';
@@ -484,7 +486,7 @@ async function send(){
484
486
  attachments=[]; renderAttachments();
485
487
  addUser(text||(sentAttachments.map(a=>(a.type==='image'?'🖼 ':'📄 ')+a.name).join(' ')));
486
488
  const msg=newAiMsg(); const content=aiContent(msg); let raw='';
487
- activeAiMsg=msg; msg._traj=[]; msg._steps=0; curSteps=0; curWorkT0=Date.now();
489
+ activeAiMsg=msg; msg._traj=[]; msg._steps=0; msg._round=++turnSeq; msg._startedAt=Date.now(); msg._endedAt=null; pendingSubagent=null; curSteps=0; curWorkT0=Date.now();
488
490
  // P0-1:本轮开始 → 运行状态条归零(上一轮的费用/首帧/工具数不许泄漏到本轮;上下文基数保留)
489
491
  runState.rawPhase='发请求(等首帧)'; runState.phase='等首帧'; runState.aborted=false;
490
492
  runState.ttftMs=null; runState.tokensPerSec=null; runState.toolCalls=0; runState.contentChars=0;
@@ -562,14 +564,18 @@ async function send(){
562
564
  if(ev.stalled) bits.push('已判定无进展,正在中止');
563
565
  runState.detail=bits.join(' · ');
564
566
  think.textContent='💭 正在工作 '+Math.floor(p/60)+' 分 '+Math.round(p%60)+' 秒 · 第 '+msg._steps+' 回合 · '+curPhase+' · '+bits.join(' · ')+'…';
565
- renderWorkStatus(); }
567
+ renderWorkStatus();
568
+ // §3.73:子代理运行中随既有 progress.subagent 实时更新右侧/末尾那一行(不新增轮询)。
569
+ if (ev.subagent && pendingSubagent) pendingSubagent = mergeSubagentProgress(pendingSubagent, ev.subagent);
570
+ updateTraceSummary(activeAiMsg);
571
+ if (ev.subagent) renderSubagentSections(); }
566
572
  if(ev.type==='text'){ curPhase='模型输出中'; runState.rawPhase='解码中(收到增量)'; runState.phase='解码中'; runState.contentChars=raw.length; onActivity(); raw+=ev.delta; update(); }
567
573
  else if(ev.type==='reasoning'){ if(!reason){ reason=addReasoning(msg); reason.dataset.full=''; } if(!reason.parentElement.open) reason.parentElement.open=true; reason.parentElement.querySelector('summary').classList.add('live'); reason.dataset.full+=ev.delta; const full=reason.dataset.full; reason.textContent=(full.length>9000?full.slice(-9000)+'\n…(思考内容较长,仅显示末尾;已 '+full.length+' 字符)':full); reason.scrollTop=reason.scrollHeight; scroll(); }
568
574
  else if(ev.type==='turnStart'){ stepsCount+=1; curSteps+=1; msg._steps=curSteps; curPhase='模型推理中'; runState.rawPhase='发请求(等首帧)'; runState.phase='等首帧'; msg._traj.push({kind:'turn', t:Date.now()}); showThink(); think.textContent='💭 第 '+msg._steps+' 回合:模型推理中…'; renderWorkStatus(); scroll(); }
569
575
  else if(ev.type==='turnEnd'){ curPhase='回合完成,进入下一回合'; showThink(); think.textContent='⏸ 第 '+msg._steps+' 回合完成(累计 '+(ev.toolSteps||0)+' 工具步)· 进入下一回合…'; renderWorkStatus(); }
570
576
  else if(ev.type==='toolStart'){ stepsCount+=1; curSteps+=1; curPhase=ev.name==='task'?'子 Agent 执行中':'执行工具中'; runState.rawPhase=curPhase; runState.phase='执行工具'; msg._steps=curSteps; msg._traj.push({kind:'tool', seq:ev.seq, name:ev.name, args:ev.args, t:Date.now(), done:false}); onActivity(); update(); renderToolStartEvent(ev); renderWorkStatus(); }
571
577
  else if(ev.type==='code'){ onActivity(); const pre=document.createElement('pre'); pre.innerHTML='<code>'+highlight(ev.code,ev.lang)+'</code>'; insertBeforeActiveMsg(pre); scroll(); }
572
- else if(ev.type==='tool'){ onActivity(); update(); runningTools.delete(ev.seq); const tj=msg._traj.find(x=>x.kind==='tool'&&x.seq===ev.seq); if(tj){ tj.done=true; tj.result=ev.result; tj.durationMs=ev.durationMs; } if(ev.name==='task'){ sessionSubs.push({seq:ev.seq, question:String(ev.args?.question||ev.args?.prompt||''), result:ev.result, durationMs:ev.durationMs, msg}); renderSubPanel(); } renderToolEvent(ev); }
578
+ else if(ev.type==='tool'){ onActivity(); update(); runningTools.delete(ev.seq); const tj=msg._traj.find(x=>x.kind==='tool'&&x.seq===ev.seq); if(tj){ tj.done=true; tj.result=ev.result; tj.durationMs=ev.durationMs; } renderToolEvent(ev); }
573
579
  else if(ev.type==='toolDenied'){ onActivity(); const tj=msg._traj.find(x=>x.kind==='tool'&&x.seq===ev.seq); if(tj){ tj.done=true; tj.denied=ev.reason||'未授权'; } renderToolDeniedEvent(ev); const d=document.createElement('div'); d.className='errline'; d.textContent='✖ '+(ev.reason==='未授权'||!ev.reason?'未授权':ev.reason)+':'+ev.name; msg.appendChild(d); scroll(); }
574
580
  else if(ev.type==='banner'){ renderBanner(ev); }
575
581
  else if(ev.type==='ask'){ runState.rawPhase='等待权限确认'; runState.phase='执行工具'; update(); renderWorkStatus(); handleAskEvent(ev); }
@@ -618,7 +624,7 @@ async function send(){
618
624
  // 具体错误形态已进日志(fetch failed 等不再绕开续跑提示)。
619
625
  d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):('连接中断,本轮未完成。已执行的工作已保存检查点——直接发送「继续」即可从断点续跑(' + Math.round((Date.now() - workT0) / 1000) + 's · ' + stepsCount + ' 步)。');
620
626
  msg.appendChild(d); scroll(); }
621
- finally{ clearInterval(hintTimer); disarm(); clearTimeout(stopFallbackTimer); stopFallbackTimer=null; stopping=false; turnAbortCtrl=null; generating=false; setBtn(); curTaskId=null; curPhase='模型推理中'; console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); activeAiMsg=null; curWorkT0=0; if(!runState.aborted){ runState.rawPhase='空闲'; runState.phase='空闲'; } else { runState.rawPhase='已中止'; runState.phase='已中止'; } renderWorkStatus(); scroll(); updateTasksPanel(); }
627
+ finally{ clearInterval(hintTimer); disarm(); clearTimeout(stopFallbackTimer); stopFallbackTimer=null; stopping=false; turnAbortCtrl=null; generating=false; setBtn(); curTaskId=null; curPhase='模型推理中'; console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; msg._endedAt=Date.now(); finalizePendingSubagent(msg); content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); renderTracePanels(); activeAiMsg=null; curWorkT0=0; if(!runState.aborted){ runState.rawPhase='空闲'; runState.phase='空闲'; } else { runState.rawPhase='已中止'; runState.phase='已中止'; } renderWorkStatus(); scroll(); updateTasksPanel(); }
622
628
  }
623
629
 
624
630
  let curTaskId=null; // 本轮 SSE 任务 id(质检 L1:主停止按钮只断本任务,不再误伤其他 tab)
@@ -1041,6 +1047,107 @@ function applyThemePreference(pref, deps) {
1041
1047
  return { pref: p, theme };
1042
1048
  }
1043
1049
 
1050
+ // —— 轨迹 / 子 Agent 视图(v0.6.21 §3.73;smoke §151 直接 eval 这段做行为断言) ——
1051
+ // 唯一统计口径:对话流每轮那一行摘要与右栏「轨迹」都走 traceRoundView / traceSummaryText,
1052
+ // 由 traceRoundForMsg 统一装配。**不允许**两处各算一套(§151 断言两处都调用同一函数)。
1053
+ function fmtTraceDur(ms) {
1054
+ const total = Math.max(0, Math.round((Number(ms) || 0) / 1000));
1055
+ if (total < 60) return total + 's';
1056
+ const m = Math.floor(total / 60);
1057
+ const s = total % 60;
1058
+ if (m < 60) return m + 'm' + (s ? s + 's' : '');
1059
+ const h = Math.floor(m / 60);
1060
+ const rm = m % 60;
1061
+ return h + 'h' + (rm ? rm + 'm' : '');
1062
+ }
1063
+ /** 子代理一行的视图:名称 · 轮次 · 工具次数 · 最后进展 · 状态(缺失字段如实显示 —,不编造)。 */
1064
+ function subagentRowView(sub) {
1065
+ const s = sub || {};
1066
+ const toolCalls = s.toolCalls == null || !Number.isFinite(Number(s.toolCalls)) ? null : Math.max(0, Math.round(Number(s.toolCalls)));
1067
+ const modelRounds = s.modelRounds == null || !Number.isFinite(Number(s.modelRounds)) ? null : Math.max(0, Math.round(Number(s.modelRounds)));
1068
+ const ago = s.lastProgressAgoMs == null || !Number.isFinite(Number(s.lastProgressAgoMs)) ? null : Math.max(0, Math.round(Number(s.lastProgressAgoMs)));
1069
+ const running = Boolean(s.running);
1070
+ const failed = !running && (s.status === 'failed' || Boolean(s.result && s.result.ok === false));
1071
+ const status = running ? 'running' : failed ? 'failed' : 'done';
1072
+ const round = Math.max(0, Math.round(Number(s.round) || 0));
1073
+ return {
1074
+ key: 'sub:' + String(s.seq == null ? '?' : s.seq),
1075
+ seq: s.seq,
1076
+ round,
1077
+ roundText: round ? '第 ' + round + ' 轮' : '—',
1078
+ name: String(s.label || s.description || s.question || '子代理').slice(0, 80),
1079
+ question: String(s.question || ''),
1080
+ toolCalls,
1081
+ toolText: toolCalls == null ? '工具次数 —' : toolCalls + ' 次工具',
1082
+ modelRounds,
1083
+ roundsText: modelRounds == null ? '自身轮次 —' : modelRounds + ' 轮',
1084
+ progress: ago == null ? '最后进展 —' : '最后进展 ' + (ago < 1000 ? '<1s' : Math.round(ago / 1000) + 's') + ' 前',
1085
+ status,
1086
+ statusText: running ? '运行中' : failed ? '失败' : '完成',
1087
+ statusCls: running ? 'run' : failed ? 'bad' : 'ok',
1088
+ running,
1089
+ durationMs: s.durationMs == null ? null : Number(s.durationMs),
1090
+ result: s.result,
1091
+ };
1092
+ }
1093
+ /** 一轮的完整视图(工具步 + 子代理 + 摘要文案);对话流与右栏消费同一个对象。 */
1094
+ function traceRoundView(round) {
1095
+ const r = round || {};
1096
+ const now = Number(r.now) || Date.now();
1097
+ const tools = (r.tools || []).map((t) => toolStepView(t, t.done));
1098
+ const subs = (r.subs || []).map(subagentRowView);
1099
+ const started = Number(r.startedAt) || 0;
1100
+ const ended = r.endedAt != null ? Number(r.endedAt) : now;
1101
+ const durationMs = started ? Math.max(0, ended - started) : 0;
1102
+ const durationText = started ? fmtTraceDur(durationMs) : '';
1103
+ const parts = ['本轮 ' + tools.length + ' 次工具调用'];
1104
+ if (subs.length) parts.push(subs.length + ' 个子代理');
1105
+ if (durationText) parts.push(durationText);
1106
+ return {
1107
+ round: Math.max(0, Math.round(Number(r.round) || 0)),
1108
+ tools,
1109
+ subs,
1110
+ toolCount: tools.length,
1111
+ subCount: subs.length,
1112
+ failed: tools.filter((t) => t.failed).length,
1113
+ durationMs,
1114
+ durationText,
1115
+ summary: parts.join(' · '),
1116
+ };
1117
+ }
1118
+ function traceSummaryText(view) { return String((view && view.summary) || ''); }
1119
+ /** 每轮的数据装配:只读 msg._traj 与传入的 sessionSubs/pending,不读 runState、不另算。 */
1120
+ function traceRoundData(msg, subs, pending, now) {
1121
+ const m = msg || {};
1122
+ const round = Math.max(0, Math.round(Number(m._round) || 0));
1123
+ const tools = (m._traj || []).filter((t) => t.kind === 'tool');
1124
+ const list = (subs || []).filter((s) => s && (s.msg === msg || (round && Number(s.round) === round)));
1125
+ if (pending && pending.msg === msg && pending.running && !list.some((s) => s.seq != null && s.seq === pending.seq)) list.push(pending);
1126
+ return { round, startedAt: m._startedAt, endedAt: m._endedAt, now, tools, subs: list };
1127
+ }
1128
+ /** 子代理运行中的实时快照(progress 事件已有字段;不新增轮询)。 */
1129
+ function mergeSubagentProgress(pending, sub) {
1130
+ if (!pending || !sub) return pending;
1131
+ const toolCalls = sub.toolCalls != null ? Number(sub.toolCalls) : pending.toolCalls;
1132
+ const modelRounds = sub.modelRounds != null ? Number(sub.modelRounds) : pending.modelRounds;
1133
+ return Object.assign({}, pending, {
1134
+ toolCalls: Number.isFinite(toolCalls) ? toolCalls : pending.toolCalls,
1135
+ modelRounds: Number.isFinite(modelRounds) ? modelRounds : pending.modelRounds,
1136
+ lastProgressAgoMs: sub.ms != null ? Number(sub.ms) : pending.lastProgressAgoMs,
1137
+ label: String(sub.label || pending.label || '子代理'),
1138
+ running: true,
1139
+ status: 'running',
1140
+ });
1141
+ }
1142
+ /** 窄窗口(<1180 右栏收起)且右栏不可见时,轨迹落到对话流末尾的可折叠区块。 */
1143
+ function traceDockVisible(width, panelVisible) {
1144
+ return layoutClassesFor(width).narrowRun && !panelVisible;
1145
+ }
1146
+ /** 点摘要时的定位目标:窄屏 → 末尾区块;宽屏/抽屉已开 → 右侧面板。 */
1147
+ function traceFocusTarget(width, panelVisible) {
1148
+ return traceDockVisible(width, panelVisible) ? 'dock' : 'panel';
1149
+ }
1150
+
1044
1151
  // —— 窄屏断点:<900 侧栏收起;<1180 右栏收起(同一判据供 initResponsive 与断言使用) ——
1045
1152
  function layoutClassesFor(width) {
1046
1153
  const w = Number(width) || 0;
@@ -1060,6 +1167,12 @@ let workingDirCache = '';
1060
1167
  let compactAvailable = false; // Web 端当前没有 /api/compact;为 true 时才显示压缩入口
1061
1168
  let runPanelOpen = false;
1062
1169
  let responsiveInitialized = false;
1170
+ // —— 轨迹收纳状态(§3.73):两个折叠区 + 展开过的轮次/工具/子代理(重渲染后保持展开) ——
1171
+ const traceFoldState = { trace: true, subs: true };
1172
+ const openTraceRounds = new Set();
1173
+ const openTraceTools = new Set();
1174
+ const openTraceSubs = new Set();
1175
+ let traceAutoOpened = false;
1063
1176
 
1064
1177
  // —— 主题:偏好写 localStorage;显式 light/dark 优先,'system' 跟随系统并实时响应 ——
1065
1178
  function themeSystemPrefersDark() {
@@ -1127,6 +1240,8 @@ function initResponsive() {
1127
1240
  const col = $('#rpCollapse'); if (col) col.onclick = () => setRunPanel(false);
1128
1241
  }
1129
1242
  initResponsive();
1243
+ // §3.73:绑定两个折叠区 + 末尾区块总开关(渲染入口在下面的轨迹收纳块)。
1244
+ initTraceUI();
1130
1245
 
1131
1246
  // —— 工具时间线 DOM:一张卡 + 可复用行(开始/完成不换节点) ——
1132
1247
  function toolStepNode(step) {
@@ -1153,7 +1268,7 @@ function toolStepNode(step) {
1153
1268
  li.classList.toggle('open', open);
1154
1269
  row.setAttribute('aria-expanded', String(open));
1155
1270
  };
1156
- const entry = { li, row, no, st, tool, args, dur, res, argsFull, meta, pre, more, step, fullOpen: false };
1271
+ const entry = { li, row, out, no, st, tool, args, dur, res, argsFull, meta, pre, more, step, fullOpen: false };
1157
1272
  more.onclick = () => { pre.textContent = step.output.full; more.hidden = true; entry.fullOpen = true; };
1158
1273
  return entry;
1159
1274
  }
@@ -1295,6 +1410,312 @@ function renderRunPanel() {
1295
1410
  $('#rpStop').onclick = () => stopTurnFromButton();
1296
1411
  $('#rpCompact').onclick = () => renderBanner({ text: '⚠ Web 端没有 /compact 端点(只有 CLI 有),本入口未启用。', warn: true });
1297
1412
 
1413
+ // ============================================================================
1414
+ // §3.73 轨迹 / 子 Agent 收纳:
1415
+ // · 对话流:每轮只留**一行**摘要(.trace-row > .trace-summary),不再内联工具卡;
1416
+ // · 宽屏:详情在右栏「轨迹 / 子Agent」两个可折叠区;
1417
+ // · 窄屏(<1180 右栏收起):同样的两块在对话流末尾的 #traceDock 里(JS 切换 hidden)。
1418
+ // · 统计口径:对话流摘要与右栏轮头都调用 traceRoundForMsg → traceRoundView,只有一套。
1419
+ // ============================================================================
1420
+ function traceRoundForMsg(msg, now) {
1421
+ return traceRoundView(traceRoundData(msg, sessionSubs, pendingSubagent, now));
1422
+ }
1423
+ function collectTraceViews() {
1424
+ return [...document.querySelectorAll('#chat .msg-ai')]
1425
+ .map((m) => traceRoundForMsg(m))
1426
+ .filter((v) => v.toolCount || v.subCount);
1427
+ }
1428
+ function ensureTraceSummary(msg) {
1429
+ if (!msg) return null;
1430
+ if (msg._traceRow) return msg._traceRow;
1431
+ const row = document.createElement('div');
1432
+ row.className = 'trace-row'; row.dataset.traceRow = '1';
1433
+ const btn = document.createElement('button');
1434
+ btn.type = 'button'; btn.className = 'trace-summary';
1435
+ btn.dataset.round = String(msg._round || 0);
1436
+ btn.dataset.src = 'traceRoundForMsg';
1437
+ btn.onclick = () => focusTraceRound(msg._round);
1438
+ row.appendChild(btn);
1439
+ msg._traceRow = row;
1440
+ msg.appendChild(row);
1441
+ return row;
1442
+ }
1443
+ /** 对话流每轮的那**一行**摘要:文案与右栏轮头同源(traceSummaryText(traceRoundView(...)))。 */
1444
+ function updateTraceSummary(msg) {
1445
+ if (!msg) return;
1446
+ const v = traceRoundForMsg(msg);
1447
+ if (!v.toolCount && !v.subCount) { if (msg._traceRow) msg._traceRow.hidden = true; return; }
1448
+ const row = ensureTraceSummary(msg);
1449
+ row.hidden = false;
1450
+ const btn = row.querySelector('.trace-summary') || row.firstChild;
1451
+ const summary = traceSummaryText(v);
1452
+ btn.textContent = '🧭 ' + summary + ' ›';
1453
+ btn.dataset.summary = summary;
1454
+ btn.dataset.round = String(v.round);
1455
+ btn.setAttribute('aria-label', '定位本轮轨迹:' + summary);
1456
+ row.classList.toggle('has-fail', v.failed > 0);
1457
+ msg.appendChild(row); // 收在本轮正文末尾(不在正文之间堆卡)
1458
+ }
1459
+ function traceToolStarted(msg, ev) {
1460
+ if (msg && ev && ev.name === 'task') {
1461
+ const question = String((ev.args && (ev.args.question || ev.args.prompt)) || '');
1462
+ pendingSubagent = {
1463
+ seq: ev.seq, msg, round: Number(msg._round) || 0,
1464
+ label: String((ev.args && ev.args.description) || question.slice(0, 60) || '子代理'),
1465
+ question, toolCalls: 0, modelRounds: 0, lastProgressAgoMs: null, running: true, status: 'running',
1466
+ };
1467
+ }
1468
+ updateTraceSummary(msg);
1469
+ renderTracePanels();
1470
+ }
1471
+ function traceToolFinished(msg, ev) {
1472
+ if (msg && ev && ev.name === 'task') {
1473
+ const question = String((ev.args && (ev.args.question || ev.args.prompt)) || '');
1474
+ const snap = pendingSubagent && pendingSubagent.seq === ev.seq ? pendingSubagent : null;
1475
+ sessionSubs.push({
1476
+ seq: ev.seq,
1477
+ label: String((snap && snap.label) || (ev.args && ev.args.description) || question.slice(0, 60) || '子代理'),
1478
+ question,
1479
+ result: ev.result,
1480
+ durationMs: ev.durationMs,
1481
+ msg,
1482
+ round: Number(msg._round) || 0,
1483
+ toolCalls: snap ? snap.toolCalls : null,
1484
+ modelRounds: snap ? snap.modelRounds : null,
1485
+ lastProgressAgoMs: snap ? snap.lastProgressAgoMs : null,
1486
+ status: ev.result && ev.result.ok === false ? 'failed' : 'done',
1487
+ });
1488
+ if (snap) pendingSubagent = null;
1489
+ renderSubPanel();
1490
+ }
1491
+ updateTraceSummary(msg);
1492
+ renderTracePanels();
1493
+ }
1494
+ function traceToolDenied(msg, ev) {
1495
+ if (msg && ev && ev.name === 'task') {
1496
+ const question = String((ev.args && (ev.args.question || ev.args.prompt)) || '');
1497
+ const snap = pendingSubagent && pendingSubagent.seq === ev.seq ? pendingSubagent : null;
1498
+ sessionSubs.push({
1499
+ seq: ev.seq, label: String((snap && snap.label) || question.slice(0, 60) || '子代理'), question,
1500
+ result: { ok: false, error: ev.reason || '未授权' }, durationMs: ev.durationMs, msg,
1501
+ round: Number(msg._round) || 0, toolCalls: snap ? snap.toolCalls : null,
1502
+ modelRounds: snap ? snap.modelRounds : null, lastProgressAgoMs: snap ? snap.lastProgressAgoMs : null,
1503
+ status: 'failed',
1504
+ });
1505
+ if (snap) pendingSubagent = null;
1506
+ renderSubPanel();
1507
+ }
1508
+ updateTraceSummary(msg);
1509
+ renderTracePanels();
1510
+ }
1511
+ /** 回合收尾时仍在跑的子代理:如实落成失败行(不让它从界面上凭空消失)。 */
1512
+ function finalizePendingSubagent(msg) {
1513
+ const p = pendingSubagent;
1514
+ if (!p || (msg && p.msg !== msg)) return;
1515
+ sessionSubs.push({
1516
+ seq: p.seq, label: p.label, question: p.question,
1517
+ result: { ok: false, error: '本轮结束时子代理仍未回传结果' }, durationMs: null, msg: p.msg,
1518
+ round: p.round, toolCalls: p.toolCalls, modelRounds: p.modelRounds,
1519
+ lastProgressAgoMs: p.lastProgressAgoMs, status: 'failed',
1520
+ });
1521
+ pendingSubagent = null;
1522
+ renderSubPanel();
1523
+ }
1524
+ function syncTraceFoldButtons() { document.querySelectorAll('[data-fold]').forEach((btn) => {
1525
+ const key = btn.dataset.fold;
1526
+ const open = traceFoldState[key] !== false;
1527
+ btn.setAttribute('aria-expanded', String(open));
1528
+ const body = document.getElementById(btn.getAttribute('aria-controls'));
1529
+ if (body) body.hidden = !open;
1530
+ });
1531
+ }
1532
+ function toggleTraceFold(key) {
1533
+ traceFoldState[key] = !(traceFoldState[key] !== false);
1534
+ syncTraceFoldButtons();
1535
+ }
1536
+ function traceScrollParent(root) {
1537
+ if (!root || !root.closest) return null;
1538
+ return root.closest('.rp-body') || root.closest('#tdBody');
1539
+ }
1540
+ function renderTraceRoundList(root, views) {
1541
+ if (!root) return;
1542
+ const scroller = traceScrollParent(root);
1543
+ const top = scroller ? scroller.scrollTop : 0;
1544
+ root.innerHTML = '';
1545
+ if (!views.length) {
1546
+ const empty = document.createElement('div'); empty.className = 'tr-empty';
1547
+ empty.textContent = '还没有可展示的轨迹——发起任务后这里按轮分组。';
1548
+ root.appendChild(empty);
1549
+ return;
1550
+ }
1551
+ for (const v of views) root.appendChild(buildTraceRoundNode(v));
1552
+ if (scroller) scroller.scrollTop = top;
1553
+ }
1554
+ function buildTraceRoundNode(v) {
1555
+ const box = document.createElement('div');
1556
+ box.className = 'tr-round' + (v.failed ? ' has-fail' : '');
1557
+ box.dataset.round = String(v.round);
1558
+ const head = document.createElement('button');
1559
+ head.type = 'button'; head.className = 'tr-round-head';
1560
+ const no = document.createElement('span'); no.className = 'tr-round-no'; no.textContent = '第 ' + v.round + ' 轮';
1561
+ const sum = document.createElement('span'); sum.className = 'tr-round-sum';
1562
+ sum.textContent = traceSummaryText(v);
1563
+ sum.dataset.summary = traceSummaryText(v); sum.dataset.src = 'traceRoundForMsg';
1564
+ head.appendChild(no); head.appendChild(sum);
1565
+ const body = document.createElement('div'); body.className = 'tr-round-body';
1566
+ if (v.tools.length) {
1567
+ const list = document.createElement('ol'); list.className = 'tl-steps tr-steps';
1568
+ for (const step of v.tools) {
1569
+ const entry = toolStepNode(step);
1570
+ paintToolStep(entry, step);
1571
+ const key = v.round + ':' + String(step.seq);
1572
+ if (openTraceTools.has(key)) { entry.out.hidden = false; entry.li.classList.add('open'); entry.row.setAttribute('aria-expanded', 'true'); }
1573
+ entry.row.addEventListener('click', () => {
1574
+ if (!entry.out.hidden) openTraceTools.add(key); else openTraceTools.delete(key);
1575
+ });
1576
+ list.appendChild(entry.li);
1577
+ }
1578
+ body.appendChild(list);
1579
+ } else {
1580
+ const d = document.createElement('div'); d.className = 'tr-empty'; d.textContent = '本轮没有工具调用'; body.appendChild(d);
1581
+ }
1582
+ const open = openTraceRounds.has(v.round);
1583
+ if (open) box.classList.add('open');
1584
+ head.setAttribute('aria-expanded', String(open));
1585
+ head.onclick = () => {
1586
+ const next = !box.classList.contains('open');
1587
+ box.classList.toggle('open', next);
1588
+ head.setAttribute('aria-expanded', String(next));
1589
+ if (next) openTraceRounds.add(v.round); else openTraceRounds.delete(v.round);
1590
+ };
1591
+ box.appendChild(head); box.appendChild(body);
1592
+ return box;
1593
+ }
1594
+ function renderTraceSubList(root, views) {
1595
+ if (!root) return;
1596
+ const scroller = traceScrollParent(root);
1597
+ const top = scroller ? scroller.scrollTop : 0;
1598
+ root.innerHTML = '';
1599
+ const rows = [];
1600
+ for (const v of views) for (const s of v.subs) rows.push(s);
1601
+ if (!rows.length) {
1602
+ const empty = document.createElement('div'); empty.className = 'tr-empty';
1603
+ empty.textContent = '本会话还没有子 Agent(task 工具会派生子 Agent)';
1604
+ root.appendChild(empty);
1605
+ return;
1606
+ }
1607
+ for (const s of rows) root.appendChild(buildTraceSubNode(s));
1608
+ if (scroller) scroller.scrollTop = top;
1609
+ }
1610
+ function buildTraceSubNode(s) {
1611
+ const box = document.createElement('div');
1612
+ box.className = 'tr-sub ' + (s.statusCls || '');
1613
+ box.dataset.seq = String(s.seq == null ? '' : s.seq);
1614
+ box.dataset.round = String(s.round);
1615
+ const head = document.createElement('button');
1616
+ head.type = 'button'; head.className = 'tr-sub-head';
1617
+ const name = document.createElement('span'); name.className = 'tr-sub-name'; name.textContent = '🤖 ' + s.name;
1618
+ const round = document.createElement('span'); round.className = 'tr-sub-meta'; round.textContent = s.roundText;
1619
+ const tools = document.createElement('span'); tools.className = 'tr-sub-meta'; tools.textContent = s.toolText;
1620
+ const progress = document.createElement('span'); progress.className = 'tr-sub-meta'; progress.textContent = s.progress;
1621
+ const state = document.createElement('span'); state.className = 'tr-sub-state ' + (s.statusCls || ''); state.textContent = s.statusText;
1622
+ [name, round, tools, progress, state].forEach((n) => head.appendChild(n));
1623
+ const body = document.createElement('div'); body.className = 'tr-sub-body';
1624
+ const q = document.createElement('div'); q.className = 'tr-sub-q'; q.textContent = '任务:' + (s.question || '—');
1625
+ const stats = document.createElement('div'); stats.className = 'tr-sub-stats';
1626
+ stats.textContent = '子代理自身:' + s.roundsText + ' · ' + s.toolText + ' · ' + s.progress
1627
+ + '(实时快照来自既有 progress.subagent,未新增轮询)';
1628
+ const rl = document.createElement('div'); rl.className = 'tr-sub-rl'; rl.textContent = '结果:';
1629
+ body.appendChild(q); body.appendChild(stats); body.appendChild(rl);
1630
+ if (s.running) { const w = document.createElement('div'); w.className = 'tr-sub-wait'; w.textContent = '仍在运行——工具次数与最后进展随 progress 事件更新…'; body.appendChild(w); }
1631
+ else body.appendChild(textBody(resultText(s.result)));
1632
+ const open = openTraceSubs.has(s.key);
1633
+ if (open) box.classList.add('open');
1634
+ head.setAttribute('aria-expanded', String(open));
1635
+ head.onclick = () => {
1636
+ const next = !box.classList.contains('open');
1637
+ box.classList.toggle('open', next);
1638
+ head.setAttribute('aria-expanded', String(next));
1639
+ if (next) openTraceSubs.add(s.key); else openTraceSubs.delete(s.key);
1640
+ };
1641
+ box.appendChild(head); box.appendChild(body);
1642
+ return box;
1643
+ }
1644
+ function renderTracePanels() {
1645
+ const views = collectTraceViews();
1646
+ const totalTools = views.reduce((a, v) => a + v.toolCount, 0);
1647
+ const totalSubs = views.reduce((a, v) => a + v.subCount, 0);
1648
+ const meta = views.length ? views.length + ' 轮 · ' + totalTools + ' 次工具 · ' + totalSubs + ' 个子代理' : '—';
1649
+ ['#rpTraceMeta', '#tdTraceMeta'].forEach((sel) => { const n = $(sel); if (n) n.textContent = meta; });
1650
+ ['#rpSubMeta', '#tdSubMeta'].forEach((sel) => { const n = $(sel); if (n) n.textContent = totalSubs ? totalSubs + ' 个' : '—'; });
1651
+ const dockMeta = $('#tdMeta'); if (dockMeta) dockMeta.textContent = meta;
1652
+ if (!traceAutoOpened && views.length) { openTraceRounds.add(views[views.length - 1].round); traceAutoOpened = true; }
1653
+ renderTraceRoundList($('#rpTraceList'), views);
1654
+ renderTraceRoundList($('#tdTraceList'), views);
1655
+ renderTraceSubList($('#rpSubList'), views);
1656
+ renderTraceSubList($('#tdSubList'), views);
1657
+ syncTraceFoldButtons();
1658
+ }
1659
+ /** 子代理运行中只刷新子代理两块(progress≈1s;不重建工具列表,少打扰)。 */
1660
+ function renderSubagentSections() {
1661
+ const views = collectTraceViews();
1662
+ const totalSubs = views.reduce((a, v) => a + v.subCount, 0);
1663
+ ['#rpSubMeta', '#tdSubMeta'].forEach((sel) => { const n = $(sel); if (n) n.textContent = totalSubs ? totalSubs + ' 个' : '—'; });
1664
+ renderTraceSubList($('#rpSubList'), views);
1665
+ renderTraceSubList($('#tdSubList'), views);
1666
+ }
1667
+ function syncTraceDock() {
1668
+ const dock = $('#traceDock'); if (!dock) return;
1669
+ const show = traceDockVisible(window.innerWidth || 0, !$('#runPanel').hidden);
1670
+ dock.hidden = !show;
1671
+ if (show) renderTracePanels();
1672
+ }
1673
+ function focusTraceRound(round) {
1674
+ const n = Math.max(0, Math.round(Number(round) || 0)); if (!n) return;
1675
+ const target = traceFocusTarget(window.innerWidth || 0, !$('#runPanel').hidden);
1676
+ if (target === 'dock') {
1677
+ const dock = $('#traceDock'); if (dock) dock.hidden = false;
1678
+ const body = $('#tdBody'); if (body) body.hidden = false;
1679
+ const toggle = $('#tdToggle'); if (toggle) toggle.setAttribute('aria-expanded', 'true');
1680
+ } else {
1681
+ setRunPanel(true);
1682
+ }
1683
+ traceFoldState.trace = true;
1684
+ openTraceRounds.add(n);
1685
+ renderTracePanels();
1686
+ const root = target === 'dock' ? $('#tdTraceList') : $('#rpTraceList');
1687
+ const el = root ? root.querySelector('[data-round="' + n + '"]') : null;
1688
+ if (el) {
1689
+ el.classList.add('focus');
1690
+ try { el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch { /* 老浏览器/无布局:跳过平滑滚动 */ }
1691
+ setTimeout(() => el.classList.remove('focus'), 1600);
1692
+ }
1693
+ }
1694
+ function initTraceUI() {
1695
+ document.querySelectorAll('[data-fold]').forEach((btn) => { btn.onclick = () => toggleTraceFold(btn.dataset.fold); });
1696
+ const toggle = $('#tdToggle');
1697
+ if (toggle) toggle.onclick = () => {
1698
+ const body = $('#tdBody'); if (!body) return;
1699
+ const show = body.hidden;
1700
+ body.hidden = !show;
1701
+ toggle.setAttribute('aria-expanded', String(show));
1702
+ };
1703
+ syncTraceFoldButtons();
1704
+ renderTracePanels();
1705
+ }
1706
+ /** 新对话/切换会话:轮次、子代理、展开状态一起归零(旧会话的轨迹不得串到新会话)。 */
1707
+ function resetTraceState() {
1708
+ turnSeq = 0;
1709
+ pendingSubagent = null;
1710
+ sessionSubs.length = 0;
1711
+ openTraceRounds.clear();
1712
+ openTraceTools.clear();
1713
+ openTraceSubs.clear();
1714
+ traceAutoOpened = false;
1715
+ renderTracePanels();
1716
+ try { renderSubPanel(); } catch { /* 子代理面板未初始化时忽略 */ }
1717
+ }
1718
+
1298
1719
  // —— 会话设置弹层:模型 / 预设 / 权限(生效值 + 来源) ——
1299
1720
  function permissionModeLabel(mode) {
1300
1721
  return ({ ask: '询问', auto: '自动', readonly: '只读' })[String(mode || '')] || String(mode || '—');
@@ -1612,15 +2033,8 @@ document.addEventListener('keydown', (e) => {
1612
2033
  });
1613
2034
 
1614
2035
  function attachTrajMeta(msg){
1615
- if(!msg || !msg._traj || !msg._traj.length) return;
1616
- const tools=msg._traj.filter(x=>x.kind==='tool');
1617
- const subs=tools.filter(x=>x.name==='task');
1618
- const meta=document.createElement('div'); meta.className='msg-meta';
1619
- const b=document.createElement('button'); b.className='trajBtn';
1620
- b.textContent='🧭 轨迹:'+(msg._steps||tools.length)+' 步 · '+subs.length+' 个子 Agent';
1621
- b.onclick=()=>openTraj(msg);
1622
- meta.appendChild(b);
1623
- msg.insertBefore(meta, msg.firstChild);
2036
+ // §3.73:不再插入 #chat 里的旧轨迹按钮卡;每轮只留一行摘要。
2037
+ updateTraceSummary(msg);
1624
2038
  }
1625
2039
  function openTraj(msg){
1626
2040
  const list=$('#tjList'); list.innerHTML='';
@@ -1677,6 +2091,8 @@ function syncPanelLayout(){
1677
2091
  const rp=$('#runPanel'); if(rp) rp.hidden=!showRun;
1678
2092
  document.body.classList.toggle('run-open', showRun&&!narrow);
1679
2093
  document.body.classList.toggle('run-drawer', showRun&&narrow);
2094
+ // §3.73:窄屏右栏收起时,轨迹/子Agent 在对话流末尾显示;右栏(抽屉)一开就让位。
2095
+ syncTraceDock();
1680
2096
  }
1681
2097
  $('#tjClose').onclick=()=>{ $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); syncPanelLayout(); };
1682
2098
  $('#trajRailBtn').onclick=()=>{
@@ -1917,6 +2333,7 @@ async function init(){
1917
2333
  renderSessionSettings();
1918
2334
  renderEmptyState();
1919
2335
  renderRunPanel();
2336
+ renderTracePanels();
1920
2337
  }
1921
2338
 
1922
2339
  // v0.4.7(T24):IDE「发送选中代码」此前不生效——扩展 POST /api/draft 时只带 text(写进**全局槽**),
@@ -2382,13 +2799,13 @@ function finalizeCurrentSession(){
2382
2799
  if(!currentSession) return;
2383
2800
  fetch('/api/session-finalize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:currentSession})}).catch(()=>{});
2384
2801
  }
2385
- async function loadSession(file){ if(currentSession && currentSession!==file) finalizeCurrentSession(); currentSession=file; chatEl.innerHTML=''; const r=await fetch('/api/session?file='+encodeURIComponent(file)); const j=await r.json(); for(const m of j.messages||[]){ if(typeof m.content==='string'&&m.content.startsWith('(系统提示)')) continue; if(m.role==='user') addUser(m.content); else { const el=newAiMsg(); aiContent(el).innerHTML=renderMarkdown(m.content); } } if(j.workspace){ const sel=$('#wsSel'); const has=[...sel.options].some(o=>o.value===j.workspace); if(has){ sel.value=j.workspace; } else { refreshWsSel(); } renderBanner({text:'↩ 已回到该会话的工作空间:'+j.workspace}); } if(j.taskState&&(j.taskState.status==='cap'||j.taskState.status==='interrupted')){ renderBanner({text:'⚠ 该会话有未完成任务(步数上限中断)——直接发送消息即可从断点续跑,已完成文件不会重复做。', warn:true}); } scrollBottom(); renderEmptyState(); }
2802
+ async function loadSession(file){ if(currentSession && currentSession!==file) finalizeCurrentSession(); currentSession=file; chatEl.innerHTML=''; resetTraceState(); const r=await fetch('/api/session?file='+encodeURIComponent(file)); const j=await r.json(); for(const m of j.messages||[]){ if(typeof m.content==='string'&&m.content.startsWith('(系统提示)')) continue; if(m.role==='user') addUser(m.content); else { const el=newAiMsg(); aiContent(el).innerHTML=renderMarkdown(m.content); } } if(j.workspace){ const sel=$('#wsSel'); const has=[...sel.options].some(o=>o.value===j.workspace); if(has){ sel.value=j.workspace; } else { refreshWsSel(); } renderBanner({text:'↩ 已回到该会话的工作空间:'+j.workspace}); } if(j.taskState&&(j.taskState.status==='cap'||j.taskState.status==='interrupted')){ renderBanner({text:'⚠ 该会话有未完成任务(步数上限中断)——直接发送消息即可从断点续跑,已完成文件不会重复做。', warn:true}); } scrollBottom(); renderEmptyState(); }
2386
2803
 
2387
2804
  // §138:主按钮在生成态=停止,必须走**同一个**停止出口(stopTurnFromButton → stopTurn → POST /api/abort)
2388
2805
  sendBtn.onclick=()=>{ if(generating){ stopTurnFromButton(); } else { send(); } };
2389
2806
  input.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); } });
2390
2807
  input.addEventListener('input',()=>{ input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; });
2391
- $('#newChat').onclick=()=>{ finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; renderEmptyState(); refreshSessions(); };
2808
+ $('#newChat').onclick=()=>{ finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); refreshSessions(); };
2392
2809
  $('#sessMgrBtn').onclick=openSessModal;
2393
2810
  $('#sessClose').onclick=()=>{ $('#sessModal').style.display='none'; };
2394
2811
  async function openSessModal(){
@@ -2404,6 +2821,6 @@ async function openSessModal(){
2404
2821
  div.appendChild(rn); div.appendChild(dl); list.appendChild(div);
2405
2822
  }
2406
2823
  }
2407
- $('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; renderEmptyState(); } };
2824
+ $('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); } };
2408
2825
  if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js').catch(()=>{}); }
2409
2826
  init();
@@ -386,6 +386,64 @@ body.run-open #scrollBottomBtn{right:326px}
386
386
  #rpStop{color:var(--err);border-color:rgba(242,90,90,.45);background:transparent;padding:4px 10px;font-size:12px}
387
387
  #rpStop[disabled]{opacity:.45}
388
388
  #rpCompact{padding:4px 10px;font-size:12px}
389
+
390
+ /* —— §3.73 轨迹收纳:对话流一行摘要 + 右栏/末尾两块(轨迹 · 子Agent) —— */
391
+ .trace-row{display:flex;justify-content:flex-end;margin:6px 0 2px}
392
+ .trace-summary{border:1px solid var(--border);background:var(--bg3);color:var(--dim);border-radius:999px;padding:2px 11px;font-size:11.5px;cursor:pointer;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
393
+ .trace-summary:hover{border-color:var(--accent2);color:var(--text)}
394
+ .trace-row.has-fail .trace-summary{border-color:rgba(242,90,90,.45);color:var(--err)}
395
+ .rp-fold-head{width:100%;border:0;background:transparent;padding:0 0 6px;margin:0;cursor:pointer;font:inherit;font-size:10.5px;letter-spacing:.5px;color:var(--faint);text-transform:uppercase;display:flex;align-items:center;gap:6px}
396
+ .rp-fold-head:hover{color:var(--text)}
397
+ .rp-fold-head .rp-fold-meta{margin-left:auto;font-size:10px;letter-spacing:0;text-transform:none;color:var(--faint)}
398
+ .rp-fold-tri{width:0;height:0;border-top:4px solid currentColor;border-left:3px solid transparent;border-right:3px solid transparent;transition:transform .15s}
399
+ .rp-fold-head[aria-expanded="false"] .rp-fold-tri{transform:rotate(-90deg)}
400
+ .tr-round{border:1px solid var(--border);border-radius:8px;background:var(--bg3);margin-bottom:6px;overflow:hidden}
401
+ .tr-round:last-child{margin-bottom:0}
402
+ .tr-round.has-fail{border-color:rgba(242,90,90,.35)}
403
+ .tr-round-head{display:flex;align-items:center;gap:6px;width:100%;padding:5px 8px;border:0;background:transparent;color:var(--dim);text-align:left;font-size:11.5px}
404
+ .tr-round-head:hover{background:var(--bg2);color:var(--text)}
405
+ .tr-round-no{flex:none;color:var(--faint);font-size:10.5px;font-variant-numeric:tabular-nums}
406
+ .tr-round-sum{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
407
+ .tr-round.has-fail .tr-round-sum{color:var(--err)}
408
+ .tr-round-body{display:none;border-top:1px dashed var(--border)}
409
+ .tr-round.open .tr-round-body{display:block}
410
+ .tr-round.focus{outline:1px solid var(--accent2);outline-offset:1px}
411
+ .tr-round-body .tl-row{grid-template-columns:18px 14px 52px minmax(0,1fr) 40px 8px;gap:5px;padding:4px 8px;font-size:12px}
412
+ .tr-round-body .tl-row .tl-args{font-size:11.5px}
413
+ .tr-round-body .tl-res{padding:0 8px 4px 86px;font-size:11px}
414
+ .tr-round-body .tl-out{padding:0 8px 8px 86px}
415
+ .tr-empty{padding:7px 9px;color:var(--faint);font-size:11.5px}
416
+ .tr-sub{border:1px solid var(--border);border-radius:8px;background:var(--bg3);margin-bottom:6px;overflow:hidden}
417
+ .tr-sub:last-child{margin-bottom:0}
418
+ .tr-sub.failed{border-color:rgba(242,90,90,.35)}
419
+ .tr-sub-head{display:flex;flex-wrap:wrap;align-items:center;gap:2px 6px;width:100%;padding:5px 8px;border:0;background:transparent;color:var(--dim);text-align:left;font-size:11.5px}
420
+ .tr-sub-head:hover{background:var(--bg2);color:var(--text)}
421
+ .tr-sub-name{min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);font-weight:600}
422
+ .tr-sub-meta{font-size:10.5px;color:var(--faint);white-space:nowrap}
423
+ .tr-sub-state{margin-left:auto;font-size:10.5px;border:1px solid var(--border);border-radius:999px;padding:0 5px;line-height:15px;color:var(--faint);white-space:nowrap}
424
+ .tr-sub-state.run{color:var(--accent2);border-color:rgba(55,196,210,.4)}
425
+ .tr-sub-state.ok{color:var(--accent)}
426
+ .tr-sub-state.bad{color:var(--err);border-color:rgba(242,90,90,.4)}
427
+ .tr-sub-body{display:none;border-top:1px dashed var(--border);padding:6px 8px;font-size:11.5px;color:var(--dim);line-height:1.6}
428
+ .tr-sub.open .tr-sub-body{display:block}
429
+ .tr-sub-q{color:var(--text);word-break:break-word}
430
+ .tr-sub-stats{margin-top:3px;color:var(--faint);font-size:10.5px}
431
+ .tr-sub-rl{margin-top:5px;color:var(--faint);font-size:10.5px}
432
+ .tr-sub-wait{color:var(--faint);font-size:11px;padding:2px 0}
433
+ #traceDock{margin:10px auto 4px;max-width:760px;border:1px solid var(--border);border-radius:12px;background:var(--bg2);overflow:hidden}
434
+ #tdToggle{display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;border:0;background:transparent;color:var(--text);font:inherit;font-size:12.5px;font-weight:600;text-align:left;cursor:pointer}
435
+ #tdToggle:hover{background:var(--bg3)}
436
+ .td-meta{margin-left:auto;font-size:10.5px;font-weight:400;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
437
+ #tdBody{border-top:1px solid var(--border);padding:8px 12px 10px}
438
+ .td-fold{margin-bottom:10px}
439
+ .td-fold:last-child{margin-bottom:0}
440
+ .td-fold-head{width:100%;display:flex;align-items:center;gap:8px;border:0;background:transparent;padding:0 0 6px;color:var(--faint);font:inherit;font-size:10.5px;letter-spacing:.5px;text-transform:uppercase;cursor:pointer}
441
+ .td-fold-head:hover{color:var(--text)}
442
+ .td-fold-head .td-fold-meta{margin-left:auto;font-size:10px;letter-spacing:0;text-transform:none}
443
+ .td-fold-body .tr-round-body .tl-row{grid-template-columns:20px 16px 64px minmax(0,1fr) 54px 10px;gap:7px;padding:5px 12px;font-size:12.5px}
444
+ .td-fold-body .tr-round-body .tl-res{padding:0 12px 5px 138px;font-size:11.5px}
445
+ .td-fold-body .tr-round-body .tl-out{padding:0 12px 9px 138px}
446
+
389
447
  @media (max-width:1180px){
390
448
  body.run-open{padding-right:0}
391
449
  #runPanel{display:none}
@@ -578,6 +636,21 @@ body.run-open #sessSheet{right:318px}
578
636
  </div>
579
637
  </div>
580
638
  <div id="chat"></div>
639
+ <!-- §3.73:窄屏(<1180)右栏收起时,轨迹/子Agent 落在对话流末尾的这一个可折叠区块里,
640
+ 不塞回每条消息之间。宽屏隐藏(详情在 #runPanel)。 -->
641
+ <section id="traceDock" aria-label="轨迹与子 Agent" hidden>
642
+ <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>
643
+ <div id="tdBody">
644
+ <section class="td-fold">
645
+ <button class="td-fold-head" type="button" data-fold="trace" aria-controls="tdTraceFoldBody" aria-expanded="true">轨迹<span class="td-fold-meta" id="tdTraceMeta">—</span></button>
646
+ <div id="tdTraceFoldBody"><div id="tdTraceList"></div></div>
647
+ </section>
648
+ <section class="td-fold">
649
+ <button class="td-fold-head" type="button" data-fold="subs" aria-controls="tdSubFoldBody" aria-expanded="true">子Agent<span class="td-fold-meta" id="tdSubMeta">—</span></button>
650
+ <div id="tdSubFoldBody"><div id="tdSubList"></div></div>
651
+ </section>
652
+ </div>
653
+ </section>
581
654
  </main>
582
655
  <footer>
583
656
  <!-- P0-1 常驻运行状态条(v0.6.18 §3.68):唯一刷新出口 renderWorkStatus()。
@@ -639,6 +712,16 @@ body.run-open #sessSheet{right:318px}
639
712
  <div class="rp-metric wide"><span class="l">本轮费用(估算)</span><span class="v" data-rk="cost" id="rpCost">—</span></div>
640
713
  </div>
641
714
  </section>
715
+ <!-- §3.73:轨迹 / 子Agent 两块可折叠区。对话流每轮只留一行摘要,细节都收在这里;
716
+ 统计与摘要同源(renderTracePanels → 同一 traceRoundForMsg 视图)。 -->
717
+ <section class="rp-sec" id="rpTraceSec">
718
+ <button class="rp-sec-title rp-fold-head" type="button" data-fold="trace" aria-controls="rpTraceFoldBody" aria-expanded="true">轨迹<span class="rp-fold-meta" id="rpTraceMeta">—</span><span class="line"></span><span class="rp-fold-tri" aria-hidden="true"></span></button>
719
+ <div id="rpTraceFoldBody"><div id="rpTraceList" class="tr-list"></div></div>
720
+ </section>
721
+ <section class="rp-sec" id="rpSubSec">
722
+ <button class="rp-sec-title rp-fold-head" type="button" data-fold="subs" aria-controls="rpSubFoldBody" aria-expanded="true">子Agent<span class="rp-fold-meta" id="rpSubMeta">—</span><span class="line"></span><span class="rp-fold-tri" aria-hidden="true"></span></button>
723
+ <div id="rpSubFoldBody"><div id="rpSubList" class="tr-list"></div></div>
724
+ </section>
642
725
  </div>
643
726
  <div class="rp-foot"><span class="grow" id="rpFootNote">停止与压缩:与状态条同一条停止出口</span><button id="rpCompact" type="button" hidden title="后端有 /compact 时才显示">压缩上下文</button><button id="rpStop" type="button" disabled>■ 停止</button></div>
644
727
  </aside>
package/src/web/server.js CHANGED
@@ -1131,6 +1131,12 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
1131
1131
  // 界面看起来一切正常(ok:true + 正文),实际一次工具都没调("看起来在工作、其实什么都没做")。
1132
1132
  const zeroNote = String(r.perf?.zeroToolCalls || '');
1133
1133
  const baseNote = r.note || (r.text ? '' : `(任务已执行 ${io.stats().toolCount} 步工具操作${io.stats().deliverables.length ? `、交付 ${io.stats().deliverables.length} 个文件` : ''},自动收尾总结未能生成——可追问「总结一下刚才的工作」)`);
1134
+ // v0.6.18 §3.74:内存降级/clamp 的事实必须同时进 **web-server.log**(不只 done.note 与 banner)——
1135
+ // 真机事后取证只拿得到日志文件;「重试了几次、压缩前后多少、最后成没成」不能只存在于界面里。
1136
+ const mergedNote = [baseNote, zeroNote].filter(Boolean).join('\n');
1137
+ if (/已压缩上下文|内存降级重试|已自动 clamp|引擎自述上下文/.test(mergedNote)) {
1138
+ srvlog('chat 内存降级 ' + taskId + ' ' + mergedNote.replace(/\n/g, ' | '));
1139
+ }
1134
1140
  send({
1135
1141
  type: 'done',
1136
1142
  budget: budgetInfo,