mingdao-harness 0.6.29 → 0.6.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -19,7 +19,7 @@ does not cover (this file is written so a reviewer can decide that, not to make
19
19
  than it is).
20
20
 
21
21
  - Node.js **>= 18.17** (`package.json` `engines`), no build step, no npm runtime dependencies.
22
- - Current version: **0.6.14** · License: **MIT**.
22
+ - Current version: **0.6.30** · License: **MIT**.
23
23
  - Repo: <https://github.com/MingDaoTCM/MingDao-Harness> · Package: `mingdao-harness` on npm.
24
24
 
25
25
  ## Quickstart (3 steps)
package/SECURITY.md CHANGED
@@ -11,7 +11,7 @@ Security fixes are provided for the **latest minor release only**.
11
11
 
12
12
  | Version | Supported |
13
13
  | --- | --- |
14
- | **0.6.x** (current: **0.6.14**) | Yes |
14
+ | **0.6.x** (current: **0.6.30**) | Yes |
15
15
  | 0.5.x and older | No |
16
16
 
17
17
  Upgrade with `npm i -g mingdao-harness@latest` (npm installs), or `mingdao update` / `mingdao rollback`
@@ -1,6 +1,6 @@
1
1
  # MingDao-Harness 架构设计
2
2
 
3
- > 版本口径:v0.6.8。本文档覆盖 `src/` 全部 **89** 个模块(`find src -name '*.js' | wc -l` = 89),
3
+ > 版本口径:v0.6.30。本文档覆盖 `src/` 全部 **90** 个模块(`find src -name '*.js' | wc -l` = 90),
4
4
  > 按层给出每个模块的职责与它守住的那条不变量;判定以文件头注释与实际实现为准,不做推断性描述。
5
5
  >
6
6
  > 阅读顺序建议:§2 模块索引(查「某件事归谁管」)→ §3 数据流(看一次回合怎么走)→ §4 已知边界(看它**保证不了**什么)。
@@ -54,7 +54,7 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
54
54
 
55
55
  ## 2. 模块索引
56
56
 
57
- 以下逐文件列出 `src/` 全部 88 个模块。每条格式为「职责 —— 关键不变量/边界」。
57
+ 以下逐文件列出 `src/` 全部 90 个模块。每条格式为「职责 —— 关键不变量/边界」。
58
58
 
59
59
  ### 2.1 内核循环与上下文(6)
60
60
 
@@ -171,8 +171,8 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
171
171
  - `help.js` —— 帮助文本的唯一来源:CLI 与 REPL 各用 `variant` 声明自己多出来的那几行 —— 两个入口的差异必须是显式的,不允许各存一份正文。
172
172
  - `index.js` —— 库公共 API:导出 `@stable`(minor 内向后兼容)与 `@experimental` 两组能力,契约与示例见 DEVELOPER.md。
173
173
 
174
- 模块计数核对:6 + 8 + 12 + 10 + 3 + 2 + 13 + 13 + 19 = **86** 个条目,其中 §2.7 的
175
- `web/util.js` / `web/app.js` 一条覆盖 2 个文件,故条目覆盖 **88** 个 `src/**/*.js`,
174
+ 模块计数核对:6 + 8 + 12 + 10 + 3 + 2 + 13 + 13 + 19 = **86** 个条目(含合并条目;`find src -name '*.js' | wc -l` = **90**),其中 §2.7 的
175
+ `web/util.js` / `web/app.js` 一条覆盖 2 个文件,故条目覆盖 **90** 个 `src/**/*.js`(每版本以 `find` 实测为准),
176
176
  与 `find src -name '*.js' | wc -l` 的结果一致。
177
177
 
178
178
  ## 3. 数据流:一次 `runTurn` 的关键路径
package/docs/CONFIG.md CHANGED
@@ -120,6 +120,17 @@ WebUI 中每个会话记住自己的工作目录:新会话记录创建时的
120
120
  ```
121
121
 
122
122
  - `allow` / `deny` 为工具名列表,支持「工具名:参数前缀」匹配(如 `bash:git *` 只放行 git 命令);
123
+ - **`deny` 按「归一后的命令」匹配(v0.6.30 起,审计 S1)**:bash 命令先按 shell 分隔符拆段
124
+ (`;` `&&` `||` `|` `&`、换行、`$()`、反引号、子 shell 括号),每段**去引号与反斜杠转义**
125
+ (`r''m` / `\rm` → `rm`)、取可执行文件的 **basename**(`/bin/rm` → `rm`)、剥掉**包装前缀**
126
+ (`env` / `sudo` / `doas` / `xargs` / `command` / `builtin` / `exec` / `nohup` / `time` / `nice` /
127
+ `ionice` / `timeout` / `stdbuf` / `setsid` / `watch` / `busybox`,含其选项与 `A=1` 赋值,
128
+ `sudo -u x` / `nice -n 5` / `timeout 5` 都能正确跨过);`sh -c "…"`、`bash -lc '…'`、`eval "…"`
129
+ 会把参数里的真命令**递归**归一(深度上限 5)。归一后同时用**全名**与 **basename** 匹配 deny,
130
+ 任一段命中即拒——`/bin/rm`、`env rm`、`sudo -u root rm`、`xargs rm`、`FOO=1 rm`、
131
+ `sh -c "rm"`、`rm|cat`、`a && rm` 与裸 `rm` 一视同仁。
132
+ **不覆盖**(字符串层面不可静态判定,必须靠 `denyStrict` + 沙箱/OS 级管控兜底):
133
+ `find / -delete`、`bash evil.sh`(脚本**文件内容**)、`$VAR` 展开、自定义包装器。
123
134
  - `deny` 优先于 `allow`;匹配不到时回落到 `mode`(`ask` 逐次确认);
124
135
  - **需要特殊授权时弹窗交互**:被 `deny` 规则拦截、或 `readonly` 模式下执行写操作时,WebUI/TUI 会弹出询问(「是否本次强制放行?」),同意即放行、拒绝/无响应即拒绝——不再静默拦截。提示里会**点名命中了哪条规则**,便于判断这次放行是不是你想要的;
125
136
  - **`denyStrict: true`(v0.6.4 起)**:把 `deny` 升级为**不可临时放行**的硬拦截(连弹窗都没有,直接拒绝并在输出里说明命中了哪条规则)。默认 `false`,保持上面那条"可放行"的既有语义。
@@ -202,7 +213,19 @@ WebUI 中每个会话记住自己的工作目录:新会话记录创建时的
202
213
  | `mode` | `warn`(默认):越界请求**放行但逐条记账**,适合「先观测再收紧」;`block`:越界请求直接拒绝并给出放行指引 |
203
214
  | `allowLoopback` | 默认 `true`。回环(`localhost` / `127.0.0.0/8` / `::1`)始终豁免——它出不了本机,把它算成外发只会制造噪音(本地模型 Ollama/vLLM 是主力场景)。**私网不算回环**,要放行需显式写进 `allow` |
204
215
 
205
- **未配置 `net` 时闸门完全不安装**,既有行为零影响(与约束引擎同款不变量)。
216
+ **未配置 `net` 时的默认行为(v0.6.30 起,审计 H2/H-12)**:
217
+
218
+ - 全局出网闸门**仍不安装**(内核既有自动路径——模型 API、技能库、模型发现、云同步——行为零影响,
219
+ 与约束引擎同款不变量;这也是 `test/smoke.js` §126 的既有行为断言);
220
+ - **但模型面向的 `fetch` 工具默认拒绝出网**(fail-closed):未配置白名单时调用 `fetch` 会直接失败,
221
+ 并在工具结果里给出可见提示「未配置 config.net 出网白名单,默认禁止出网」+ 该把哪个域名加进
222
+ `config.net.allow`。回环/私网/元数据地址不走这条默认拒绝——它们由 SSRF 防护给出更准确的
223
+ 「内网」「元数据」拒绝理由(本来就连不出去)。
224
+ - `fetch` **不再属于只读工具**(v0.6.30 起归入出网类 `NET_TOOLS`):默认 `ask` 档逐次确认,
225
+ `readonly` 档也不再自动放行(要放行请显式配置 `config.net` 或逐次确认)。
226
+ - **`read` → `fetch` 的叠加提示**:同一交互窗口内先 `read` 过本地文件、随后 `fetch` 出网时,
227
+ 确认文案会标出「该请求可能携带刚读取的本地内容(文件列表)」+ 目标 URL,让"读文件 → 外传"
228
+ 这条链路在用户眼前显形,而不是混在一条普通的出网询问里。
206
229
 
207
230
  查看与自证:
208
231
 
@@ -316,6 +339,14 @@ bash 工具**默认**从子进程环境中剥离敏感变量(`*_API_KEY`、`*_
316
339
  { "bashEnvKeep": ["NPM_TOKEN"], "bashEnvFilter": false }
317
340
  ```
318
341
 
342
+ v0.6.29 起,**子进程 env 名单与日志脱敏词表是同一份判定**(`src/redact.js` 的
343
+ `SENSITIVE_ENV_NAMES` / `SECRET_NAME_SEGMENTS` / `isSensitiveEnvName`):`PGPASSWORD`、
344
+ `MY_CRED`(`*_CRED` 段)、`DATABASE_URL` / `*_URL|URI|DSN` 连接串,以及大写 `SK-` 前缀
345
+ 都同时覆盖"从子进程剔除"与"日志/账本/审计脱敏"两侧;新增一项只改这一处,测试会扫源码
346
+ 禁止第二份名单。账本另在**对象层**按键名整值掩码(`api_key`/`apikey`/`token`/`password`/
347
+ `passwd`/`secret`/`credential`/`authorization`/`cookie`/`private_key` 等,大小写与 `-`/`_`
348
+ 归一),再叠加值级规则做双保险(`redactDeep` 导出路径同样适用)。
349
+
319
350
  ## 定价覆盖
320
351
 
321
352
  内置价格表数据时点为 2026-08(`/api/state` 的 `pricingAsOf` 字段),官方调价后无需等
@@ -379,12 +410,31 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
379
410
  设备 token 存凭证库(`credentials.json` 的 `sync` 字段),配置里只留非秘密项。自建自签证书
380
411
  过渡阶段可加 `"insecure": true`(正式证书就绪后删除)。详见 README「云同步与多用户协作」。
381
412
 
382
- 同步**服务端**(`mingdao sync-server`)的安全默认(**v0.6.29 起变更**):
413
+ 同步**服务端**(`mingdao sync-server`)的安全默认(**v0.6.29 起变更,v0.6.30 收紧**):
383
414
  `SYNC_HOST` 默认 **`127.0.0.1`**(仅本机),注册默认 **`closed`**。对外提供服务需显式设置
384
415
  `SYNC_HOST=0.0.0.0` 并配置 `SYNC_CERT`/`SYNC_KEY`(TLS);接纳新用户需显式设置
385
416
  `MINGDAO_SYNC_REGISTRATION=invite` + `MINGDAO_SYNC_INVITE_CODES=码1,码2`(推荐)或 `open`。
386
- **已有账号不受影响**(配对/同步/改密不变);本机首启在「未显式配置 + 只监听回环 + 回环来源」时
387
- 仍可自助注册。公网自建建议至少 `invite`。
417
+ **已有账号不受影响**(配对/同步/改密不变)。
418
+
419
+ - **本机首启**:v0.6.30(N-10)起默认 closed **无条件**成立,不再按「回环来源」自动放行
420
+ (反代部署下外部客户端的 socket 恒为 `127.0.0.1`,且 `X-Forwarded-For` 可由客户端自填)。
421
+ 本机直连自助注册有两条**显式**引导:① 设置 `MINGDAO_SYNC_ALLOW_LOOPBACK_REGISTRATION=1`——仅当
422
+ 请求来自本机直连、未启用可信反代配置且不带任何转发头时生效;② 默认 closed 且首次启动(还没有
423
+ 账号)时,服务端在 `os.tmpdir()/mingdao-sync-bootstrap-<端口>.txt`(0600)生成**一次性引导码**,
424
+ 本机 `mingdao sync login` 会自动读取并随注册请求提交;每成功注册一次即轮换(旧码失效),服务器
425
+ 关闭时删除文件。经代理/带 `X-Forwarded-For` 的请求即使持有引导码也仍被拒绝。
426
+ - **可信反代**:`SYNC_TRUST_PROXY=1` 或 `SYNC_TRUST_PROXY_HOPS=<n>` 才读 `X-Forwarded-For`。
427
+ `SYNC_TRUST_PROXY_HOPS=1` 取**右起第 1 跳**(最靠近服务端);未设置这两个变量时完全忽略 XFF,
428
+ 来源限流不可用轮换 XFF 绕过。
429
+ - **数据面配额**:`/api/sessions/push`、`/api/sessions/pull` 每账号每分钟最多
430
+ `SYNC_REQUESTS_PER_MIN`(默认 **60**)次,每日字节配额 `SYNC_QUOTA_MB_PER_DAY`
431
+ (默认 **512** MB,`0` = 关闭);超限返回 429,并带 `ACCOUNT_RATE_LIMIT` /
432
+ `DAILY_QUOTA_EXCEEDED`、`usedBytes`、`limitBytes`、`resetAt` 等可见说明。
433
+ - **WebUI 权限**:未配置访问令牌时只能选 `ask` / `readonly`,`auto` 会被服务端拒绝
434
+ (`PERMISSION_AUTO_REQUIRES_TOKEN`);`/api/state` 的 `permissionAutoAllowed` / `permissionNote`
435
+ 会说明该限制。配置 `web.token` / `--auth-token` / `MINGDAO_WEB_TOKEN` 后不再受限。
436
+
437
+ 公网自建建议至少 `invite`。
388
438
 
389
439
  ## 会话日志与「带上文」(跨会话连续性)
390
440
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.29",
3
+ "version": "0.6.31",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -79,6 +79,56 @@ const SUBAGENT_MAX_STEPS = 24;
79
79
  * · 真干活的回合每隔几秒就有工具结果或正文增量,60 分钟零增量在任何正常回合都不可能。
80
80
  *
81
81
  * 可配置:config.noProgressTimeoutMs(0/负数/NaN/Infinity 一律回落默认——**不能**用它关掉看门狗)。 */
82
+ // v0.6.31(负责人实测):TTFT 提示"每个回合各打一次"在长审计里刷屏。
83
+ //
84
+ // 现象:本地模型跑长审计时,聊天区反复出现「⏱ 首帧 10.9s(第 1 次模型请求…)」十几次,
85
+ // 而且**每次都写"第 1 次"**——因为每个「回合」都是新 turnProgress(用户点「继续」/自动续跑
86
+ // 都算新回合),`llmCalls===1` 这个条件对每个回合都成立。
87
+ //
88
+ // 判定抽成**纯函数**(可单测、可变异),状态放**进程级**(WebUI 每请求新建 agent,只有进程级
89
+ // 才能跨回合节流;键用会话名/工作目录)。
90
+ const TTFT_NOTICE_DEFAULT_QUIET_MS = 10 * 60 * 1000;
91
+ /** @type {Map<string, {ms: number|null, at: number}>} */
92
+ const ttftNoticeState = new Map();
93
+
94
+ /**
95
+ * 本次是否该打印 TTFT 提示。
96
+ * 规则:① 每回合只在第 1 次请求时考虑;② 同一会话 quietMs 内不重复;
97
+ * ③ 首帧比上次打印时**明显变慢**(≥ slowdownRatio 倍)则例外——劣化信号不能被节流吃掉。
98
+ * @param {{llmCalls:number, ttftMs:number, lastMs?:number|null, lastAt?:number, now:number,
99
+ * quietMs?:number, slowdownRatio?:number}} a
100
+ * @returns {{print: boolean, slowdown: boolean}}
101
+ */
102
+ export function shouldPrintTtftNotice({ llmCalls, ttftMs, lastMs = null, lastAt = 0, now, quietMs = TTFT_NOTICE_DEFAULT_QUIET_MS, slowdownRatio = 2 }) {
103
+ if (Number(llmCalls) !== 1) return { print: false, slowdown: false };
104
+ const ms = Number(ttftMs);
105
+ if (!Number.isFinite(ms)) return { print: false, slowdown: false };
106
+ const slowdown = Number(lastMs) > 0 && ms >= Number(lastMs) * Number(slowdownRatio);
107
+ if (slowdown) return { print: true, slowdown: true };
108
+ if (!Number(lastAt)) return { print: true, slowdown: false };
109
+ return { print: now - Number(lastAt) >= Number(quietMs), slowdown: false };
110
+ }
111
+
112
+ /**
113
+ * 读/写某会话的 TTFT 提示节流状态(进程级,上限 200 个键防泄漏)。
114
+ * @param {string} key
115
+ * @returns {{ms: number|null, at: number}}
116
+ */
117
+ export function ttftNoticeStateFor(key) {
118
+ const k = String(key || 'default');
119
+ let v = ttftNoticeState.get(k);
120
+ if (!v) {
121
+ v = { ms: null, at: 0 };
122
+ if (ttftNoticeState.size >= 200) {
123
+ // TS7 下 `MapIterator#value` 是 `string | undefined`:先收窄再删(与原来等价,size>=200 时必有键)。
124
+ const oldest = ttftNoticeState.keys().next().value;
125
+ if (oldest !== undefined) ttftNoticeState.delete(oldest);
126
+ }
127
+ ttftNoticeState.set(k, v);
128
+ }
129
+ return v;
130
+ }
131
+
82
132
  export const DEFAULT_NO_PROGRESS_TIMEOUT_MS = 3600000;
83
133
 
84
134
  /** 父回合在等待 `task` 子代理时额外给的一点点宽限(ms)。
@@ -1269,12 +1319,60 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1269
1319
  return null;
1270
1320
  }
1271
1321
  };
1272
- const stripOrphanCalls = () => {
1273
- const last = messages[messages.length - 1];
1274
- if (last?.role === 'assistant' && Array.isArray(last.tool_calls) && last.tool_calls.length) {
1275
- messages[messages.length - 1] = { ...last, tool_calls: undefined };
1322
+ // 回合收尾的唯一配对补齐点(审计 v0.6.29 C-01 / 严重-1)。
1323
+ //
1324
+ // 旧实现 `stripOrphanCalls()` 只会把**最后一条** assistant 的 tool_calls 清空:
1325
+ // 中断发生在工具执行期间时,末条往往是 `tool` 消息,清理静默失效,留下
1326
+ // assistant(2 个 tool_calls) + tool(仅第 1 个) 的残缺历史。OpenAI 兼容端点要求
1327
+ // tool_calls 与 tool 消息严格一一对应,这份历史会让会话**从此每一轮都 400**
1328
+ //(WebUI 还会把它 appendMessages 落盘成永久坏会话)。
1329
+ //
1330
+ // 正确做法是**补齐**而不是删除:按最后一条带 tool_calls 的 assistant 的 id 集合,
1331
+ // 给没有对应 tool 响应的调用插入 `{role:'tool', tool_call_id, content:'(已中断,未执行)'}`。
1332
+ // 保留 assistant.tool_calls 是必须的——删掉它会让同批次**已经执行**的工具结果与历史错位。
1333
+ // 插入点紧跟该 assistant 之后**连续**的 tool 消息块(历史修复时也不会被后续消息隔开)。
1334
+ const INTERRUPTED_TOOL_CONTENT = '(已中断,未执行)';
1335
+ const finishTurn = () => {
1336
+ let idx = -1;
1337
+ for (let i = messages.length - 1; i >= 0; i--) {
1338
+ const m = messages[i];
1339
+ if (m?.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
1340
+ idx = i;
1341
+ break;
1276
1342
  }
1277
- };
1343
+ }
1344
+ if (idx < 0) return;
1345
+ const answered = new Set();
1346
+ for (let i = idx + 1; i < messages.length; i++) {
1347
+ const m = messages[i];
1348
+ if (m?.role === 'tool' && m.tool_call_id != null) answered.add(String(m.tool_call_id));
1349
+ }
1350
+ const missing = [];
1351
+ const seen = new Set();
1352
+ for (const tc of messages[idx].tool_calls) {
1353
+ if (!tc || tc.id == null) continue; // 无 id 无法配对(SSE 兜底 id 已保证有 id)
1354
+ const key = String(tc.id);
1355
+ if (answered.has(key) || seen.has(key)) continue;
1356
+ seen.add(key);
1357
+ missing.push(tc.id);
1358
+ }
1359
+ if (!missing.length) return;
1360
+ let insertAt = idx + 1;
1361
+ while (insertAt < messages.length && messages[insertAt]?.role === 'tool') insertAt += 1;
1362
+ messages.splice(insertAt, 0, ...missing.map((id) => ({ role: 'tool', tool_call_id: id, content: INTERRUPTED_TOOL_CONTENT })));
1363
+ };
1364
+ // D5(审计 v0.6.29 H-02 / 高-2):本批工具结果先在内存里按 `tool_calls` 原序排队,
1365
+ // 整批结束后统一回填。旧实现里「拒绝/参数解析失败」在 prepTool 就地 push、成功项在
1366
+ // finishTool 才 push,批次 [A 成功, B 被拒] 的历史顺序会变成 B → A,与 assistant.tool_calls
1367
+ // 错位(严格网关按顺序校验时同样 400)。队列记录 idx(在 res.toolCalls 里的原始序号),
1368
+ // 回填前排序;中断/异常出口也会先 flush 已执行的结果,再由 finishTurn 补齐其余占位。
1369
+ const pendingToolMessages = /** @type {{idx: number, msg: any}[]} */ ([]);
1370
+ const flushToolMessages = () => {
1371
+ if (!pendingToolMessages.length) return;
1372
+ pendingToolMessages.sort((a, b) => a.idx - b.idx);
1373
+ for (const p of pendingToolMessages) messages.push(p.msg);
1374
+ pendingToolMessages.length = 0;
1375
+ };
1278
1376
  try {
1279
1377
  // 同回合只读工具去重(Hermes C4):相同 name+args 的只读调用只执行一次,结果复用回填。
1280
1378
  // v0.4.1 P1 修复:turnToolCache 必须声明在 while 之外、for 轮内——此前在 while 体内每步重建,
@@ -1305,7 +1403,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1305
1403
  // 置真、却没有任何收敛点——工具跑完仍会走回这里发出**下一次请求**(实测:SIGINT 之后
1306
1404
  // chat 仍被调用 2 次)。这里与工具循环里的检查一起把它收口成「立刻停止、如实回报 aborted」。
1307
1405
  if (aborted || noProgressFired) {
1308
- stripOrphanCalls();
1406
+ finishTurn();
1309
1407
  // v0.6.13(A):无进展看门狗触发——如实报告,而不是当作"正常结束"或"用户中断"。
1310
1408
  if (noProgressFired) {
1311
1409
  markOutcome('stalled');
@@ -1415,7 +1513,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1415
1513
  // 统计不可读 / 无价格数据时判据返回 null(不误拦、也不静默放行——主检查会告警)。
1416
1514
  const msg = preflightBlockMessage(g.dailyLimitYuan, usedTodayWithInflight(), worst);
1417
1515
  if (msg) {
1418
- stripOrphanCalls();
1516
+ finishTurn();
1419
1517
  return {
1420
1518
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
1421
1519
  note: msg,
@@ -1441,14 +1539,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1441
1539
  DEFAULT_MODEL
1442
1540
  );
1443
1541
  if (d.action === 'block') {
1444
- stripOrphanCalls();
1542
+ finishTurn();
1445
1543
  return {
1446
1544
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
1447
1545
  note: d.message, durationMs: Date.now() - startedAt, perf: perf(),
1448
1546
  };
1449
1547
  }
1450
1548
  if (d.action === 'already-cheapest') {
1451
- stripOrphanCalls();
1549
+ finishTurn();
1452
1550
  return {
1453
1551
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
1454
1552
  note: `今日费用已达上限(实际 ¥${String((todayCost() ?? 0).toFixed(4))}),且已在最便宜模型上执行,已暂停——调整 config.costGuard 或明天自动恢复。`,
@@ -1467,7 +1565,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1467
1565
  downgraded = true;
1468
1566
  io.print(style(d.message, C.yellow));
1469
1567
  } else {
1470
- stripOrphanCalls();
1568
+ finishTurn();
1471
1569
  return {
1472
1570
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
1473
1571
  note: `费用护栏想降级到 ${target},但它与当前服务商不一致或缺少 API Key——已暂停执行。请把 config.costGuard.downgradeModel 改为与当前模型同服务商(当前:${curPc.name})的模型名,或调高 dailyLimitYuan。`,
@@ -1586,10 +1684,31 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1586
1684
  // (状态条/右栏已有首帧值)。长审计里本地模型会有十几次请求,重复打印只会刷屏。
1587
1685
  // 可选的"后续明显劣化警告"本轮未实现:现有 firstTokenAt 是回合级的,
1588
1686
  // 不能用来量后续请求的 TTFT(否则会得到负值),不为一条提示去改测量口径。
1589
- if (turnProgress.llmCalls === 1) {
1590
- try {
1591
- io.print(style(`⏱ 首帧 ${(ttft / 1000).toFixed(1)}s(第 ${turnProgress.llmCalls} 次模型请求;本地模型这一项直接反映 prefill 快慢)`, C.dim));
1592
- } catch {}
1687
+ // v0.6.31:会话级节流(见文件顶部 shouldPrintTtftNotice)——长审计里「继续」会产生
1688
+ // 很多新回合,每回合都打一次会把聊天区刷满;但首帧**明显变慢**时必须照常报(劣化信号)。
1689
+ {
1690
+ const nk = String(cfg?.sessionRef?.name || cfg?.sessionKey || workingDir || 'default');
1691
+ const st = ttftNoticeStateFor(nk);
1692
+ const verdict = shouldPrintTtftNotice({
1693
+ llmCalls: turnProgress.llmCalls,
1694
+ ttftMs: ttft,
1695
+ lastMs: st.ms,
1696
+ lastAt: st.at,
1697
+ now: Date.now(),
1698
+ quietMs: Number(cfg?.ttftNoticeQuietMs) > 0 ? Number(cfg.ttftNoticeQuietMs) : TTFT_NOTICE_DEFAULT_QUIET_MS,
1699
+ });
1700
+ if (verdict.print) {
1701
+ st.ms = ttft;
1702
+ st.at = Date.now();
1703
+ try {
1704
+ io.print(style(
1705
+ verdict.slowdown
1706
+ ? `⏱ 首帧 ${(ttft / 1000).toFixed(1)}s(比上次明显变慢,本地模型这一项直接反映 prefill 快慢)`
1707
+ : `⏱ 首帧 ${(ttft / 1000).toFixed(1)}s(第 ${turnProgress.llmCalls} 次模型请求;本地模型这一项直接反映 prefill 快慢)`,
1708
+ verdict.slowdown ? C.yellow : C.dim
1709
+ ));
1710
+ } catch {}
1711
+ }
1593
1712
  }
1594
1713
  markProgress('模型增量');
1595
1714
  if (d.text) io.writeText(d.text);
@@ -1611,7 +1730,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1611
1730
  io.endTurn();
1612
1731
  // v0.6.13(A):看门狗触发的中止——如实报告"连续 N 分钟无进展",不冒充 Ctrl+C/正常结束
1613
1732
  if (noProgressFired) {
1614
- stripOrphanCalls();
1733
+ finishTurn();
1615
1734
  markOutcome('stalled');
1616
1735
  return {
1617
1736
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false, stalled: true,
@@ -1620,7 +1739,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1620
1739
  };
1621
1740
  }
1622
1741
  if (aborted) {
1623
- stripOrphanCalls();
1742
+ finishTurn();
1624
1743
  markOutcome('aborted', { aborted: true });
1625
1744
  return { text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: outcome.aborted, upstreamTruncated, durationMs: Date.now() - startedAt, perf: perf() };
1626
1745
  }
@@ -1740,7 +1859,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1740
1859
  // 对活着但内存吃紧的引擎是错误处置);交给下面 describeUpstreamError 的独立分型。
1741
1860
  const engineShedCaches = /engine shed its caches and stays up|allocator wall/i.test(String(e?.message || ''));
1742
1861
  if (!engineShedCaches && (e?.status === 507 || /memory_refusal|内存不足|内存拒绝/i.test(String(e?.message || '')))) {
1743
- stripOrphanCalls();
1862
+ finishTurn();
1744
1863
  markOutcome('done');
1745
1864
  return {
1746
1865
  text: null,
@@ -1880,7 +1999,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1880
1999
  // 串行化只读子代理避免并发峰值(只读 read/ls/glob/grep 仍并行,它们不额外触发大 prefill)。
1881
2000
  const subagentIsLocal = resolveModelCaps(cfg, subagentModel(cfg, modelName)).isLocal;
1882
2001
 
1883
- // 预检:解析参数 → PreToolUse 钩子 → 权限检查;拒绝/失败只回填不执行(返回 null)
2002
+ // 预检:解析参数 → PreToolUse 钩子 → 权限检查;拒绝/失败只回填不执行。
2003
+ // v0.6.29(H-02):拒绝项不再就地 push(那会与成功项的执行顺序错位),而是返回
2004
+ // `{denied}`,由批次循环按 `tool_calls` 原序排入回填队列。
1884
2005
  // task 工具标记 readOnly 时也可并行(评估 A4:只读子代理 Promise.all)
1885
2006
  async function prepTool(/** @type {any} */ tc) {
1886
2007
  const name = tc.function?.name || '';
@@ -1905,8 +2026,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1905
2026
  io.renderToolDenied(name, {}, parsedArgs.deniedReason);
1906
2027
  if (auditOn) auditEntry({ denied: true, reason: parsedArgs.auditReason });
1907
2028
  // 审计(长生成截断循环):给出明确分块指引,避免模型反复用单个超大 write 撞输出上限
1908
- messages.push({ role: 'tool', tool_call_id: tc.id, content: parsedArgs.message });
1909
- return null;
2029
+ return { tc, name, denied: parsedArgs.message };
1910
2030
  }
1911
2031
  args = parsedArgs.args;
1912
2032
 
@@ -1915,8 +2035,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1915
2035
  if (hook.decision === 'block') {
1916
2036
  io.renderToolDenied(name, args, '被钩子阻止');
1917
2037
  if (auditOn) auditEntry({ denied: true, reason: `PreToolUse 钩子阻止:${String(hook.reason || '').slice(0, 200)}` });
1918
- messages.push({ role: 'tool', tool_call_id: tc.id, content: `工具被 PreToolUse 钩子阻止:${hook.reason}` });
1919
- return null;
2038
+ return { tc, name, denied: `工具被 PreToolUse 钩子阻止:${hook.reason}` };
1920
2039
  }
1921
2040
 
1922
2041
  const isMcp = name.startsWith('mcp__');
@@ -1924,8 +2043,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1924
2043
  if (presetToolSet && !presetToolSet.has(isMcp ? name.slice(5) : name)) {
1925
2044
  io.renderToolDenied(name, args, `不在预设工具白名单内(${activePresetName || 'preset'})`);
1926
2045
  if (auditOn) auditEntry({ denied: true, reason: '预设工具白名单拦截' });
1927
- messages.push({ role: 'tool', tool_call_id: tc.id, content: `工具 ${name} 不在当前预设的工具白名单内,已拒绝执行。` });
1928
- return null;
2046
+ return { tc, name, denied: `工具 ${name} 不在当前预设的工具白名单内,已拒绝执行。` };
1929
2047
  }
1930
2048
  let allowed = false;
1931
2049
  if (isMcp && mcp?.isReadonly(name)) {
@@ -1947,8 +2065,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1947
2065
  turnLedger.permission({ name, mode: permission?.mode ?? cfg.permission ?? null, decision: 'deny', source: 'permission.check' });
1948
2066
  turnLedger.toolCall({ callId: tc.id, name, rawArgs: args, args, permission: { decision: 'deny' } });
1949
2067
  turnLedger.toolResult({ callId: tc.id, name, ok: false, blocked: true, ms: 0, error: '权限拒绝' });
1950
- messages.push({ role: 'tool', tool_call_id: tc.id, content: '用户拒绝了该工具的执行权限。' });
1951
- return null;
2068
+ return { tc, name, denied: '用户拒绝了该工具的执行权限。' };
1952
2069
  }
1953
2070
  // v0.5.0 A3 ①:领域约束(PreToolUse)——权限放行之后、执行之前强制。
1954
2071
  // 与权限引擎的分工:权限回答「用户是否允许」,约束回答「领域是否允许」;两者都通过才执行。
@@ -1990,8 +2107,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1990
2107
  if (cv?.event) auditConstraint(cv.event);
1991
2108
  // 被约束拦下的调用不会有 tool.result,这里补一条终态,避免账本出现「只有调用没有结果」的悬空步
1992
2109
  turnLedger.toolResult({ callId: tc.id, name, ok: false, blocked: true, ms: 0, error: '领域约束拦截' });
1993
- messages.push({ role: 'tool', tool_call_id: tc.id, content: `【领域约束】${reason}` });
1994
- return null;
2110
+ return { tc, name, denied: `【领域约束】${reason}` };
1995
2111
  }
1996
2112
  return { tc, name, args, isMcp };
1997
2113
  }
@@ -2125,10 +2241,23 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2125
2241
  const pv = checkPostTool(constraints, prep.name, typeof result === 'string' ? safeParse(result) : result);
2126
2242
  if (pv?.rejected) auditConstraint(pv.event);
2127
2243
  // 回填正文(复用前缀 / 约束拒绝文案 / 按窗口截断)单源在 tools-flow.js 的 toolResultMessage()
2128
- messages.push({
2129
- role: 'tool',
2130
- tool_call_id: prep.tc.id,
2131
- content: toolResultMessage(text, { cached: prep.cached, cap: toolResultCap, post: pv }),
2244
+ // v0.6.29(H-02):不在这里直接 push——先按 `tool_calls` 原序入队,整批结束后统一回填。
2245
+ pendingToolMessages.push({
2246
+ idx: prep.idx,
2247
+ msg: {
2248
+ role: 'tool',
2249
+ tool_call_id: prep.tc.id,
2250
+ content: toolResultMessage(text, { cached: prep.cached, cap: toolResultCap, post: pv }),
2251
+ },
2252
+ });
2253
+ }
2254
+
2255
+ // 拒绝项(参数解析失败/钩子阻止/预设白名单/权限拒绝/领域约束)同样按原序入队。
2256
+ // 它们没有执行、没有耗时/账本结果,只回填一条 tool 消息——顺序必须与成功项一致。
2257
+ function finishDenied(/** @type {any} */ prep) {
2258
+ pendingToolMessages.push({
2259
+ idx: prep.idx,
2260
+ msg: { role: 'tool', tool_call_id: prep.tc.id, content: prep.denied },
2132
2261
  });
2133
2262
  }
2134
2263
 
@@ -2137,7 +2266,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2137
2266
  // v0.6.5(审计 BUG-025):工具执行期间用户按了 Ctrl+C → 立刻停止,不再执行后续批次
2138
2267
  // (此前没有任何检查点,长工具序列会把中断信号拖到整串跑完)
2139
2268
  if (aborted) {
2140
- stripOrphanCalls();
2269
+ // v0.6.29(H-02):已执行批次的结果先按原序落盘,再由 finishTurn 补齐未执行项;
2270
+ // 否则中断会把"已经跑完的工具"变成"未执行"占位,账实不符。
2271
+ flushToolMessages();
2272
+ finishTurn();
2141
2273
  return {
2142
2274
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: true,
2143
2275
  note: '已按 Ctrl+C 中断(工具执行期间收到中断信号),本回合未再发出请求。',
@@ -2149,9 +2281,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2149
2281
  while (i < res.toolCalls.length) {
2150
2282
  const tc = res.toolCalls[i];
2151
2283
  const prep = /** @type {any} */ (await prepTool(tc));
2284
+ prep.idx = i; // v0.6.29(H-02):记录在 res.toolCalls 中的原始序号,回填时按它排序
2152
2285
  const name = tc.function?.name || '';
2153
- let batchable = canBatch && Boolean(prep) && !prep.isMcp && READONLY_BATCH.has(name);
2154
- if (!batchable && canBatch && Boolean(prep) && name === 'task' && prep.args?.readOnly === true && !subagentIsLocal) batchable = true;
2286
+ let batchable = canBatch && !prep.denied && !prep.isMcp && READONLY_BATCH.has(name);
2287
+ if (!batchable && canBatch && !prep.denied && name === 'task' && prep.args?.readOnly === true && !subagentIsLocal) batchable = true;
2155
2288
  batch.push({ prep, batchable });
2156
2289
  i += 1;
2157
2290
  if (!batchable) break;
@@ -2178,18 +2311,27 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2178
2311
  );
2179
2312
  prefix.forEach((b, idx) => finishTool(b.prep, results[idx].r, results[idx].t0));
2180
2313
  for (const b of batch.slice(prefix.length)) {
2181
- if (!b.prep) continue;
2314
+ if (b.prep.denied != null) {
2315
+ finishDenied(b.prep);
2316
+ continue;
2317
+ }
2182
2318
  const t0 = Date.now();
2183
2319
  finishTool(b.prep, await runTool(b.prep), t0);
2184
2320
  }
2185
2321
  } else {
2186
2322
  for (const b of batch) {
2187
- if (!b.prep) continue; // 拒绝/失败成员已在 prepTool 回填
2323
+ if (b.prep.denied != null) { // 拒绝/失败成员:只入队,不执行
2324
+ finishDenied(b.prep);
2325
+ continue;
2326
+ }
2188
2327
  const t0 = Date.now();
2189
2328
  finishTool(b.prep, await runTool(b.prep), t0);
2190
2329
  }
2191
2330
  }
2192
2331
  }
2332
+ // v0.6.29(H-02):整批结束后按 `tool_calls` 原序一次性回填,保证
2333
+ // assistant.tool_calls 与 tool 消息逐项对齐(拒绝项不再抢在成功项前面)。
2334
+ flushToolMessages();
2193
2335
  // v0.2.8 步数预留收尾(对齐 DSH):仅「最后一轮」的末步追加收尾指令,
2194
2336
  // 让模型在末轮输出总结;中间轮不注入(交给自动续跑继续干活,而非提前收尾中断)。
2195
2337
  if (steps === stepLimit - 1 && round === maxRounds - 1) {
@@ -2221,7 +2363,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2221
2363
  if (res.finish === 'length') {
2222
2364
  emptyRounds += 1;
2223
2365
  if (emptyRounds >= maxEmptyRounds) {
2224
- stripOrphanCalls();
2366
+ finishTurn();
2225
2367
  return {
2226
2368
  text: res.text || null,
2227
2369
  reasoning: res.reasoning || '',
@@ -2329,7 +2471,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2329
2471
  }
2330
2472
  io.endTurn();
2331
2473
  // 步数上限:清掉未执行的 tool_calls,避免下一轮/恢复后 API 400
2332
- stripOrphanCalls();
2474
+ finishTurn();
2333
2475
  // v0.6.2(用户实测:「步数受限未能产出交付物,需要继续追问方能继续下一步」):
2334
2476
  // 预算用尽必须**明确告诉用户**发生了什么、以及怎么继续。此前这里一行都不打印,
2335
2477
  // 用户只看到一段总结,很容易误判为「任务已完成」,只能自己追问。
@@ -2425,10 +2567,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2425
2567
  } catch (/** @type {any} */ err) {
2426
2568
  // 审计 P2-6(v0.4.2):工具管线异常(hooks.pre / permission.check / prepTool 等)沿大 try 上抛时,
2427
2569
  // assistant tool_calls 已 push 进 messages 却无对应 tool 回填——会话恢复后 API 因孤儿 tool_call_id 400。
2428
- // 清理孤儿调用后再抛(stripOrphanCalls 此前只在中断/收尾路径调用)。
2429
- stripOrphanCalls();
2570
+ // v0.6.29(C-01):先落盘已执行批次的结果(H-02 队列),再把缺失项补齐成"未执行"占位再抛;
2571
+ // 只删 assistant.tool_calls 会让**已经执行**的 tool 结果失去配对,同样 400。
2572
+ flushToolMessages();
2573
+ finishTurn();
2430
2574
  throw err;
2431
2575
  } finally {
2576
+ // v0.6.29(C-01):最后一道保险——所有出口(正常/中断/预算/异常)都做一次配对补齐。
2577
+ // 显式调用点仍保留:它们让"返回前历史已经完整",这里兜住任何新增的 early return。
2578
+ flushToolMessages();
2579
+ finishTurn();
2432
2580
  currentAc = null;
2433
2581
  currentUsage = null; // 回合结束:避免 runTurn 之外调用的 spawnTask 写入陈旧累加器
2434
2582
  // v0.6.13(A):看门狗计时器必须随回合结束清掉(每回合一个,长驻 WebUI 里不能泄漏);