mingdao-harness 0.6.14 → 0.6.15

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.8** · License: **MIT**.
22
+ - Current version: **0.6.14** · License: **MIT**.
23
23
  - Repo: <https://github.com/MingDaoTCM/MingDao-Harness> · Package: `mingdao-harness` on npm.
24
24
 
25
25
  ## Quickstart (3 steps)
@@ -66,11 +66,12 @@ enforced by the kernel is described as a declaration, not as a control.
66
66
  | **Sandbox** — `config.sandbox` | `"off"` (default), `"readonly"` (whole filesystem read-only, `/tmp` writable, network available), `"safe"` (read-only + no network via `unshare-net`; working dir and `/tmp` writable). Implemented with **bubblewrap** on **Linux only**. | If the platform is not Linux or `bwrap` is missing/incapable, it **downgrades to `off`** and says so in the tool result — it never silently pretends to be sandboxed. It wraps the `bash` tool only; the file tools are governed by the permission modes, not by this. |
67
67
  | **Credential separation** | API keys live in `<home>/credentials.json` (`<home>` defaults to `~/.mingdao`, overridable with `MINGDAO_HOME`), written atomically and forced to mode **600**. `config.json` is key-free, so it is shareable and committable. Resolution order: environment variable → local credential store → legacy `config.json` field. Manage with `mingdao key`. | The legacy `config.json` `"apiKey"` field is still **read** for backward compatibility — do not use it. Keys are stored as plaintext in your home directory (no OS keychain), so any process running as your user can read them. `mingdao key status` and logs only ever print a masked form. |
68
68
  | **Telemetry opt-out** | Only **packaged desktop builds** send anything, and only one event: after an update finishes downloading, a `POST` to `https://harness.mingdao.ai/updok` with `{"kind":"update","os":...,"ver":...}` — no user identifier, no session, no code. | It is **opt-out, not opt-in**: on by default in packaged builds. Disable with `MINGDAO_NO_TELEMETRY=1` or `"telemetry": false` in `config.json`; updates still download and install. The CLI and the WebUI send **no** telemetry at all. |
69
+ | **Update-package signature** | The packaged desktop app verifies an **ed25519 detached signature** over the installer bytes against a public key pinned in the source (`desktop/update-verify.js`). A signature that is present but invalid is always rejected. | The release pipeline does not sign builds yet, so current builds verify as "no signature" and follow the default policy: **install, but state that the signature was not verified**. Set `MINGDAO_REQUIRE_UPDATE_SIGNATURE=1` to **reject** unsigned updates (fail-closed, recommended for high-assurance deployments). A feed controlled by an attacker can strip the signature — that is exactly the downgrade this switch closes off. |
69
70
  | **PID ownership** | Before `killTask` / `stopDaemon` signals a pid, it verifies the pid still belongs to the process we started (Linux reads `/proc/<pid>/cmdline`, macOS falls back to `ps -ww -o command=`). Three-valued: ours / definitely not ours / unknown. | **Windows has neither `/proc` nor `ps`**, and the project deliberately does not take a PowerShell/WMI dependency for this check, so ownership is reported as unknown and the caller falls back to best-effort "process is alive". Deploy on Linux or macOS if you need precise pid-ownership checks. |
70
71
  | **Egress gate** — `config.net` | `{ "allow": ["api.deepseek.com", "*.internal.corp", "10.0.0.0/8"], "mode": "warn", "allowLoopback": true }` — `warn` (default) logs and allows, `block` refuses. Decisions are made at a single HTTP exit and recorded per event (host, port, decision, matched rule); `mingdao net report` exports the record as evidence. Non-`fetch` paths (`node:https` sync, `safe-fetch` for skill/registry/model discovery) are wired into the same decision point. Loopback is exempt; private ranges are not. | **It does not cover child processes.** Your own `curl` inside `bash`, **MCP server processes**, `config.tools` and Pack-spawned subprocesses, and the Electron shell's update check all make their own requests that the gate never sees. It proves *"the kernel did not exfiltrate"*, **not** *"this machine never egressed"*. `net.jsonl` records hosts/ports/decisions, never request bodies or full URLs. If unconfigured, the gate is not installed at all. For process-level enforcement use an egress proxy or firewall. |
71
72
  | **SSRF protection** | One implementation (`src/ssrf-guard.js`): literal private / loopback / link-local / cloud-metadata hosts are refused (IPv4 plus all IPv6 forms, including IPv4-mapped, NAT64 and zone-ids); a hostname is resolved and **every** resolved address is checked; a hostname that fails to resolve is refused. The **validated IP is pinned to the connection layer** (via the `lookup` option of `node:http(s)`), so the check-time and connect-time resolutions cannot differ — this closes DNS rebinding. Redirects are re-checked per hop and cross-origin hops drop credential headers using a reverse allowlist. | The rule is fail-closed by design, which means it can over-block unusual-but-legitimate targets (odd IPv6 forms, `%`-scoped hosts); the fix is to use a literal, routable address or a proper hostname. It protects harness-initiated requests; it cannot constrain a subprocess' own networking (see the row above). |
72
73
  | **Pack trust** | A project-level `.mingdao/packs/**` is **not mounted** until you explicitly run `mingdao pack trust <project-dir>`. Trust is pinned to content: if the Pack's content changes afterwards, trust lapses automatically. `mingdao pack verify <dir>` is **static by default** and does not execute Pack code; only `--runtime` imports it. | `manifest.permissions` (`fs` / `net` / `env`) is a **declaration, not enforcement**. The kernel does not enforce it: `pack.mjs` is imported into the **host process** and runs with full Node privileges — it can read and write any file and reach the network — and Pack-contributed tools are **not** subject to `config.permission`. Packs you list explicitly in `config.packs` bypass the trust gate, because that is your own written authorization. Treat installing a Pack as equivalent to running its code. |
73
- | **Execution ledger** | Per-turn event streams under `<home>/ledger/`, each event chained to the previous one by a truncated SHA-256 digest starting from a fixed genesis value, plus a `run.end` seal sidecar. Details are redacted on write and again on export. Inspect with `mingdao ledger list\|show\|export\|verify` (there is also `replay`, which re-evaluates recorded calls against your *current* rules offline). | Hash chain + seal prove "unchanged and not truncated **since it was written**". There is **no trusted timestamp and no signature** (optional signing is not implemented), so it is **not** audit-grade non-repudiation — `verify` says so in its own output. |
74
+ | **Execution ledger** | Per-turn event streams under `<home>/ledger/`, each event chained to the previous one by a truncated SHA-256 digest starting from a fixed genesis value, plus a `run.end` seal sidecar. The seal carries an **ed25519 source signature** (key at `<home>/ledger-key.json`, mode 600, generated on first seal). Details are redacted on write and again on export. Inspect with `mingdao ledger list\|show\|export\|verify` — `verify` reports chain/seal **and** provenance in four states (valid / unsigned / invalid / unverifiable) — plus `replay`, which re-evaluates recorded calls against your *current* rules offline; `ledger --sign-key` shows or rotates the key. | Hash chain + seal prove "unchanged and not truncated **since it was written**"; the signature proves "written by the holder of that key" — but the key lives on the same machine with the same permissions, so it does **not** stop a local attacker who can already rewrite the ledger. There is **no trusted timestamp**, and replacing the key makes old ledgers report "signed by another key". It is provenance, not audit-grade non-repudiation. |
74
75
  | **WebUI exposure** | Binds `127.0.0.1:3820` by default. When bound to a non-loopback address without a configured token, the server generates a random token for that run and prints a `?token=` URL. Tokens also accepted via `X-MingDao-Token` or `Authorization: Bearer`. Host header is checked against loopback/bind names (anti-DNS-rebinding), cross-site browser requests are rejected, and cloud-metadata endpoints are always refused. | On loopback with no token configured there is **no authentication** (local trust by design). With `host: 0.0.0.0` the Host-header check is relaxed and the **token becomes the only boundary**, so a token is mandatory in that mode. The token is deployment-level: everyone sharing it is the same user, and task/session lists are mutually visible. |
75
76
 
76
77
  ## Command index
@@ -87,7 +88,7 @@ Commands are grouped here for evaluation; `mingdao --help` and `src/help.js` are
87
88
  | `mingdao web [port]` | Start the WebUI (default `http://127.0.0.1:3820`); `--auth-token` (or `--auth-token=-` from stdin). |
88
89
  | `mingdao diagnose` | Build one redacted diagnostic bundle (logs / audit / config) for bug reports. |
89
90
  | `mingdao audit [n]` | Show the tool-call audit log (default last 20 entries). |
90
- | `mingdao ledger list\|show\|export\|verify\|replay` | Execution ledger: inspect, export, verify the hash chain, or replay against current rules. |
91
+ | `mingdao ledger list\|show\|export\|verify\|replay` | Execution ledger: inspect, export, verify the hash chain and source signature, or replay against current rules. `ledger --sign-key [--generate\|--force]` shows or rotates the ed25519 signing key. |
91
92
  | `mingdao net report\|policy` | Egress record export and current `config.net` policy. |
92
93
  | `mingdao key [status\|set\|remove\|import] [provider]` | Manage the local credential store (never writes keys into `config.json`). |
93
94
  | `mingdao pack list\|verify\|info\|new\|trust\|untrust` | Discover, statically validate, scaffold, and trust domain Packs. |
@@ -111,7 +112,7 @@ Commands are grouped here for evaluation; `mingdao --help` and `src/help.js` are
111
112
  The full reference is **Chinese**; these are the entry points worth knowing:
112
113
 
113
114
  - [`docs/CONFIG.md`](docs/CONFIG.md) — every `config.json` field: permission rules, sandbox, `config.net`, hooks, MCP, sync, cost guard.
114
- - [`docs/CONFIG.en.md`](docs/CONFIG.en.md) — English configuration reference (verified against the code): permissions, sandbox & env filtering, egress gate and its limits, cost guard, WebUI exposure, audit/ledger, full field table.
115
+ - [`docs/CONFIG.en.md`](docs/CONFIG.en.md) — English configuration reference (verified against the code): permissions, sandbox & env filtering, egress gate and its limits, cost guard, WebUI exposure, audit/ledger provenance, local-endpoint pre-flight/serialization, timeouts and the turn-level watchdog, full field table.
115
116
  - [`docs/PACK-API.md`](docs/PACK-API.md) — the Pack contract (manifest, contributions, constraint kinds, and which of them are **not** implemented).
116
117
  - [`docs/PROVIDERS.md`](docs/PROVIDERS.md) — built-in providers and writing a custom OpenAI-compatible provider.
117
118
  - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — module map and data flow.
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.8**) | Yes |
14
+ | **0.6.x** (current: **0.6.14**) | 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`
@@ -48,7 +48,8 @@ Please do not open a public issue for an unfixed vulnerability. Credit is offere
48
48
  - `src/permissions.js` — the permission decision engine: `allow` / `deny` / `denyStrict` matching and
49
49
  the `readonly` / `ask` / `auto` modes.
50
50
  - `desktop/**` — the Electron shell: window navigation and `openExternal` handling, the contextBridge
51
- preload surface, IPC handlers.
51
+ preload surface, IPC handlers, and update-package signature verification (a bypass of the pinned key or
52
+ of the fail-closed policy is in scope).
52
53
 
53
54
  Reports that affect anything else in `src/` are welcome too; the list above is where the trust
54
55
  boundaries live.
@@ -90,14 +91,18 @@ boundaries live.
90
91
  5. **Treat Packs as untrusted code.** Run `mingdao pack verify <dir>` (static; `--runtime` executes the
91
92
  Pack) and read `pack.mjs` before `mingdao pack trust`; never trust a freshly cloned repository you
92
93
  have not reviewed.
93
- 6. **Enable the audit trail and the ledger.** Keep `config.audit` on, review `mingdao audit`
94
- periodically, and run `mingdao ledger verify` on turns you care about. For unattended jobs, prefer
94
+ 6. **Enable the audit trail and the ledger, and keep the signing key.** Keep `config.audit` on,
95
+ review `mingdao audit` periodically, and run `mingdao ledger verify` on turns you care about; a new
96
+ ledger is signed automatically with `<home>/ledger-key.json` (mode 600). Back that file up: replacing
97
+ it turns every previously signed ledger into "signed by another key". For unattended jobs, prefer
95
98
  `readonly` or `denyStrict` over `auto`.
96
99
  7. **Sandbox shell execution where you can.** On Linux with `bubblewrap` installed, set
97
100
  `config.sandbox` to `readonly` or `safe` (no network). Elsewhere the sandbox downgrades to `off` and
98
101
  says so — compensate with OS-level isolation or a container.
99
102
  8. **Run the desktop build only from a source you trust** and verify the checksum published on the
100
- Release page; current desktop builds are unsigned (see below).
103
+ Release page; current desktop builds are unsigned. If you require update provenance, set
104
+ `MINGDAO_REQUIRE_UPDATE_SIGNATURE=1` so an update package without a valid signature is **rejected**
105
+ (the default is to install but explicitly report that the signature was not verified).
101
106
 
102
107
  ## Known boundaries
103
108
 
@@ -114,15 +119,23 @@ price them in.
114
119
  2. **Packs have no isolation.** `pack.mjs` runs in the host process with full Node privileges, and
115
120
  `manifest.permissions` is not enforced by the kernel. Capability brokering or subprocess isolation
116
121
  for Packs requires an architectural change and is not implemented.
117
- 3. **Electron code signing and update-package signature verification are not done.** Current builds
118
- are unsigned (see [`docs/CODE-SIGNING.md`](docs/CODE-SIGNING.md), which describes the steps to sign);
119
- the auto-updater does not verify a signature on the downloaded update package beyond the transport.
122
+ 3. **Electron code signing / notarization is still not done.** Current builds are unsigned (see
123
+ [`docs/CODE-SIGNING.md`](docs/CODE-SIGNING.md), which describes the steps to sign). Update-package
124
+ **source-signature verification is implemented** (`desktop/update-verify.js`: ed25519 over the
125
+ installer bytes, public key pinned in the source; a present-but-invalid signature is always rejected),
126
+ but the release pipeline does not sign builds yet. Consequently the default policy is
127
+ `warn-and-install` (install, and state that the signature was not verified); set
128
+ `MINGDAO_REQUIRE_UPDATE_SIGNATURE=1` to make unsigned updates fail closed.
120
129
  4. **IDE plugin token storage needs hardening.** The VS Code and JetBrains plugins drive the local
121
130
  WebUI over loopback; the broader item (secure token storage, and distinguishing HTTP 401 from
122
131
  "server unreachable") is registered as open work.
123
- 5. **Ledger events are tamper-evident but not signed.** The hash chain plus the `run.end` seal detect
124
- modification and truncation after the fact, but there is no trusted timestamp and optional signing
125
- (`--sign-key`) is not implemented — so the ledger is not audit-grade non-repudiation, and
126
- `mingdao ledger verify` says exactly that in its own output.
132
+ 5. **Ledger provenance has the same limits as the machine it runs on.** The hash chain plus the
133
+ `run.end` seal detect modification and truncation after the fact, and since v0.6.11 the seal also
134
+ carries an **ed25519 source signature** (auto-generated key at `<home>/ledger-key.json`, mode 600;
135
+ `mingdao ledger --sign-key` to inspect/rotate; `verify` reports valid / unsigned / invalid /
136
+ unverifiable, with "unverifiable" also failing). The boundary: the private key and the ledger share
137
+ the same machine and permissions, so this does not defend against a local attacker who can already
138
+ rewrite the ledger; there is no trusted timestamp; and replacing the key makes old ledgers report
139
+ "signed by another key". It is provenance, not audit-grade non-repudiation.
127
140
 
128
141
  We do not claim this project is vulnerability-free, and this document is not a marketing statement.
package/docs/CONFIG.md CHANGED
@@ -531,6 +531,52 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
531
531
  既有默认, 1800s)、无进展看门狗 = clamp(max(实测首帧×100, 参考prefill×20), 10min, 60min);
532
532
  **你显式配置的 `timeout.*` / `noProgressTimeoutMs` 永远优先**(显式 > 自适应)。
533
533
 
534
+ #### 本地模型:并发与内存(v0.6.15)
535
+
536
+ 真机证据(v0.6.14,8081 的 `mtplx-qwen38-27b-optimized-quality`):长任务中途被引擎拒绝——
537
+ `insufficient memory: the request exceeded available GPU memory (sustained critical memory pressure
538
+ during prefill; aborted before the allocator wall). The engine shed its caches and stays up; this
539
+ request failed.` 同一错误此前在 60091 的 35B 上也出现过 → **不是模型问题,是本机内存压力**。
540
+ 观测到该回合此前有子代理在跑(13 轮 / 17 次工具调用)→ **父回合 + 子代理同时向同一本地引擎
541
+ 发大 prefill**(各自要一份 KV cache)是主要来源。所以内核现在对**同一 `baseUrl`** 的本地端点
542
+ 在途请求做**串行化/限量**:
543
+
544
+ - **默认 1 并发**:父回合、子代理(`task`)、**端点预检**(探针打的是同一个 `/chat/completions`)
545
+ 共用同一条**进程级 Promise 队列**,谁先到谁先跑,后到的排队(不是忙等,释放即移交名额)。
546
+ - **配置**(与 `timeout` 同级,写在 `config.json` 顶层):
547
+
548
+ ```json
549
+ {
550
+ "localConcurrency": 1,
551
+ "localQueueTimeoutMs": 1800000
552
+ }
553
+ ```
554
+
555
+ | 字段 | 默认 | 含义 |
556
+ | --- | --- | --- |
557
+ | `localConcurrency` | `1` | 同一本地 `baseUrl` 的同时在途请求上限。`0` = **显式退出串行化**(不限并发);正整数上限 16;非法/负数回落 1 |
558
+ | `localQueueTimeoutMs` | `1800000`(30 分钟) | 排队等待上限。超过即报**可见错误**(不重试、不死等),避免"看起来在跑、其实永远排不到" |
559
+
560
+ - **云端端点不受影响**:`baseUrl` 不是回环/内网(如 `api.deepseek.com`)时直接放行——不排队、
561
+ 不记日志、不增加任何延迟。`customModels.<名>.local: true` 可把经反代的本地端点纳入串行化。
562
+ - **可见性**:入队时写一行 `本地端点串行化:等待 N 个在途请求(端点,并发上限 1)`
563
+ (WebUI 落 `~/.mingdao/logs/web-server.log`;CLI 写 stderr);界面状态条/进度行显示
564
+ `本地端点已串行化(防内存压力)`,发送前的 `📏 本轮上下文` 预告在**已有在途请求**时会加一条强警告
565
+ `并发会加剧内存压力 … 建议减少子代理数量`。
566
+ - **排队超时的报错**(`code=LOCAL_QUEUE_TIMEOUT`):
567
+ `本地端点排队超时(等待 1800.0s,仍有 1 个在途请求,并发上限 1):为避免内存压力已在本机端点串行化——可调大 config.localQueueTimeoutMs,或减少子代理/并行会话。`
568
+ - **引擎活着但丢了缓存**:上游文案含 `engine shed its caches and stays up` / `allocator wall` 时,
569
+ 错误分型会明确"**该引擎未崩溃**(自己丢弃缓存后继续运行)"、**不要重启引擎**,处置是
570
+ 压上下文(`contextBudget` / `/compact`)、减少并发、等它自行恢复后重试。这与"引擎崩了/连接被拒"
571
+ (`ECONNREFUSED`、连接被断开)是**不同**的两种判定,后者才需要确认端口与进程。
572
+ - **排查步骤**(还遇到内存拒绝时):
573
+ 1. 看 `web-server.log` 有没有 `本地端点串行化:等待 N 个在途请求`——有排队说明闸门在工作,
574
+ 若仍被拒绝,说明单请求本身就超了引擎内存(压 `contextBudget`,或让 `customModels.<名>.contextWindow`
575
+ ≤ 引擎自述的 `n_ctx`,见上一节);
576
+ 2. 确认没有多个会话/多个本地模型同时跑长任务(每个模型各占一份显存与 KV cache);
577
+ 3. 临时把 `localConcurrency` 保持 `1`,并减少子代理数量(读只读子代理在本地模型下已不并行);
578
+ 4. **不要**因为 `engine shed its caches and stays up` 去重启引擎——它没有崩;重启只会重新加载模型。
579
+
534
580
  ### 用本地模型测 harness(测试者步骤清单)
535
581
 
536
582
  > 这一段是**操作清单**,不是原理说明。按顺序做,能在几分钟内区分"端点不行"和"harness 不行"。
@@ -653,6 +699,11 @@ mingdao ledger verify <runId> --key <文件> # 用指定公钥验签(本
653
699
  顺手一次就会把签名密钥清掉、让全部历史账本变成"另一把密钥签发"。
654
700
  - **默认开启**:新账本在首次收尾时若本机还没有密钥会**自动生成**并签名(可选签名在合规上等于
655
701
  没有签名)。封条里带 `sig{alg,keyId,value}`,`run.end` 链内带 `sigKey`(公钥指纹)。
702
+ - **`run.start.permissionSource`(v0.6.17 §3.64)**:记录本回合生效权限的**来源**——`explicit`
703
+ (用户/调用方显式选择,如 WebUI 权限档)/ `preset`(预设声明生效)/ `config`(`config.json`
704
+ 的 `permission`)/ `cli`(后台/调度任务由 CLI 参数指定)/ `default`(以上都没有,落到内置缺省
705
+ `ask`;WebUI 的 `config` 分支在缺字段时也归 `config`)。`permission` 仍是生效值、`LEDGER_VERSION`
706
+ 不变;旧账本没有该字段照样 `verify` 通过(`show` 只是少一行可选的「权限来源」)。
656
707
  - **`ledger verify` 的三种结论**(前两者退出码 0,第三者非 0):
657
708
 
658
709
  | 结论 | 含义 | 退出码 |
@@ -671,3 +722,9 @@ mingdao ledger verify <runId> --key <文件> # 用指定公钥验签(本
671
722
  (按模型分账、每日费用柱状图、缓存命中率、Batch 子项),文件落当前目录
672
723
  `mingdao-cost-report-<月>.md`。数据源为 cache-stats.jsonl(含缓存折扣与 Batch 半价的真实口径)。
673
724
 
725
+
726
+ ## 开发工具:变异验证的 fail-safe
727
+
728
+ `node test/mutate/run.mjs` 启动时会先自检残留标记(能自愈的自愈,冲突/在飞则拒绝),
729
+ 并用 `test/mutate/.generated/mutate.lock` 做互斥;详见 `test/mutate/FAILSAFE.md`。
730
+ 手工自检:`node test/mutate/check.mjs`(`--allow-dirty` 可放行脏工作树)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.14",
3
+ "version": "0.6.15",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -18,15 +18,16 @@ import { redactSecrets } from './redact.js';
18
18
  import { checkCostGuard, costGuardConfig, todayCost, preflightBlockMessage, roundGuardAction } from './cost-guard.js';
19
19
  import { recordCacheStats, packDailyCost } from './cachestats.js';
20
20
  import { estimateCost, cacheSplit, isPeakHour } from './pricing.js';
21
- import { resolveProviderConfig, createProvider } from './providers/index.js';
21
+ import { resolveProviderConfig, createProvider, localGateSnapshot, localConcurrencyLimit } from './providers/index.js';
22
22
  import { compileConstraints, checkPreTool, checkPostTool, checkOutput, blockedOutputText } from './constraints.js';
23
23
  import { createLedger, newRunId } from './ledger.js';
24
24
  import { registerEgressSink } from './net-guard.js';
25
25
  import { getActivePackContext } from './packs.js';
26
26
  // v0.6.11(审计 P1-1 拆分第三刀):工具编排两端的判据单源在 tools-flow.js——
27
27
  // ① 本轮可见哪些工具(只读档 × 预设白名单 × 已用工具快照 × MCP 只读标注);
28
- // ② 工具结果怎么回填(序列化 / 复用前缀 / 约束拒绝文案 / 按窗口截断)。
29
- import { READONLY_TIER_SET, visibleToolsFor, serializeToolResult, toolResultMessage } from './tools-flow.js';
28
+ // ② 工具结果怎么回填(序列化 / 复用前缀 / 约束拒绝文案 / 按窗口截断);
29
+ // ③ 工具参数怎么解析(v0.6.17 第四刀:JSON + 容错 + 归一化 + 错误文案)。
30
+ import { READONLY_TIER_SET, visibleToolsFor, serializeToolResult, toolResultMessage, parseToolArgs } from './tools-flow.js';
30
31
 
31
32
  const MAX_STEPS = 24;
32
33
  // 子代理步数上限:审计/精读类只读子任务需要读多个文件 + 交叉引用,12 步易在「读不全」时被截断
@@ -144,11 +145,16 @@ const fmtTok = (/** @type {any} */ n) => Number(n || 0).toLocaleString('en-US');
144
145
  *
145
146
  * v0.6.13(问题 1 修法):**有预检实测就必须由它推导**,并在文案里如实标注
146
147
  * ①这是外推、②长上下文下外推会低估(附实测差值);只有在**没有预检数据**时才退回保守文案。
148
+ *
149
+ * v0.6.15(§3.59 第 3 条):本地端点**已有在途请求**(父回合/子代理/预检之一)时加一句强警告——
150
+ * 真机 8081 的内存拒绝就是"父子回合同时 prefill"撞出来的;串行化已经兜住资源,但用户仍应
151
+ * 知道"并发会加剧内存压力、应减少子代理"。
147
152
  * @param {{ promptTokens: number, contextWindow?: number, isLocal?: boolean,
148
- * firstTokenMs?: number, budget?: number, probe?: any }} input
153
+ * firstTokenMs?: number, budget?: number, probe?: any,
154
+ * localBusy?: boolean, localActive?: number, localQueued?: number, localConcurrency?: number }} input
149
155
  * @returns {{ text: string, warn: boolean, promptTokens: number, ratio: number, estimateMs: number|null }}
150
156
  */
151
- export function prefillForecast({ promptTokens, contextWindow, isLocal, firstTokenMs, budget, probe = null }) {
157
+ export function prefillForecast({ promptTokens, contextWindow, isLocal, firstTokenMs, budget, probe = null, localBusy = false, localActive = 0, localQueued = 0, localConcurrency = 1 }) {
152
158
  const p = Math.max(0, Math.round(Number(promptTokens) || 0));
153
159
  const win = Number(contextWindow) > 0 ? Number(contextWindow) : 0;
154
160
  const ratio = win > 0 ? p / win : 0;
@@ -187,10 +193,22 @@ export function prefillForecast({ promptTokens, contextWindow, isLocal, firstTok
187
193
  `建议缩小任务(新建会话、只给必要文件、先 /compact)或调大 config.timeout.firstTokenMs——` +
188
194
  `但注意调大它只会让"失败"更晚被发现,不会让 prefill 变快。`
189
195
  : '';
196
+ // v0.6.15(§3.59 第 3 条):本地端点已有在途请求 → **强警告**(不是提示)。
197
+ // 真机证据:父回合 + 子代理同时向同一台 8081 引擎发大 prefill,引擎在 prefill 阶段报
198
+ // insufficient memory("engine shed its caches and stays up");串行化解决资源冲突,
199
+ // 但用户仍必须知道"并发会加剧内存压力",否则会继续开并行子代理/多会话。
200
+ const limit = Number(localConcurrency) >= 0 ? Math.floor(Number(localConcurrency)) : 1;
201
+ const active = Math.max(0, Math.floor(Number(localActive) || 0));
202
+ const queued = Math.max(0, Math.floor(Number(localQueued) || 0));
203
+ const concurrencyNote = Boolean(isLocal) && Boolean(localBusy)
204
+ ? `\n ⚠ 本地端点当前已有 ${active} 个在途请求${queued > 0 ? `、${queued} 个在排队` : ''}(并发上限 ${limit}):` +
205
+ `**并发会加剧内存压力**(父子回合同时 prefill 会被引擎以 insufficient memory 拒绝);` +
206
+ `${limit <= 1 ? '已串行化,后来的请求会排队等待' : `已限制为 ${limit} 并发,仍可能叠加内存压力`}——建议减少子代理数量、避免多个长任务会话同时跑。`
207
+ : '';
190
208
  // v0.6.13(A 追加):把**端点预检**结果并进这条预告——"这个端点行不行"与"这次要 prefill 多少"
191
209
  // 必须一起出现在发送前,而不是等出事后翻日志(引擎实际加载的模型名与配置名不一致也在这里说)。
192
210
  const probeLine = probe && probe.note ? `\n 端点预检:${probe.note}` : '';
193
- return { text: `${head}\n ${timing}${probeLine}${advice}`, warn, promptTokens: p, ratio, estimateMs: est ? est.ms : null };
211
+ return { text: `${head}\n ${timing}${probeLine}${advice}${concurrencyNote}`, warn: warn || Boolean(concurrencyNote), promptTokens: p, ratio, estimateMs: est ? est.ms : null };
194
212
  }
195
213
 
196
214
  /**
@@ -276,6 +294,15 @@ export function describeUpstreamError(/** @type {any} */ err) {
276
294
  hint =
277
295
  '上游拒绝的是**它的 prompt 模板**(不是网络问题):该端点的模板不接受当前 messages 形态(典型:按单轮 query 设计的 chatflow/模板,收到的是 OpenAI 多轮 messages)。' +
278
296
  '这类端点通常也**不返回 tool_calls**——请改用支持 function calling 的模型/端点,或让网关把 messages 映射成它要的单轮入参。';
297
+ } else if (/engine shed its caches and stays up|allocator wall/.test(t)) {
298
+ // v0.6.15(§3.59 第 2 条):**引擎活着但丢了缓存**——与"引擎崩了/连接被拒"是两件事。
299
+ // 真机原文(8081):`insufficient memory: … aborted before the allocator wall.
300
+ // The engine shed its caches and stays up; this request failed.` 引擎自己丢了 KV cache
301
+ // 并继续服务,**没有崩溃**;此时建议"重启引擎"是错的(重启只会把模型重新加载一遍)。
302
+ hint =
303
+ '本地引擎**内存/显存不足**,但它**未崩溃**(原文:engine shed its caches and stays up——引擎自己丢弃缓存后仍在运行)。' +
304
+ '**不要重启引擎**(重启不会增加显存,反而要重新加载模型)。处置:压缩上下文(减小 config.contextBudget 或 /compact)、' +
305
+ '减少并发子任务/并行会话(同一本地端点已按 config.localConcurrency 串行化),然后可直接重试——引擎通常会自行恢复。';
279
306
  } else if (/memory_refusal|insufficient memory|gib|内存不足|内存拒绝/.test(t)) {
280
307
  hint =
281
308
  '本地引擎**内存/显存不足**(在 prefill 前就拒绝了)。处置:压缩上下文(减小 config.contextBudget 或 /compact)、减少并发子任务,或重启模型服务释放内存后重试。';
@@ -302,9 +329,10 @@ export function describeUpstreamError(/** @type {any} */ err) {
302
329
  * @param {{ phase?: string, elapsedMs: number, modelRounds?: number, toolCalls?: number,
303
330
  * toolExecuted?: number, contentChars?: number, ttftMs?: number|null,
304
331
  * pendingTool?: { name: string, ms: number }|null, tokensPerSec?: number|null,
305
- * subagent?: { label?: string, modelRounds?: number, toolCalls?: number, at?: number }|null }} p
332
+ * subagent?: { label?: string, modelRounds?: number, toolCalls?: number, at?: number }|null,
333
+ * localQueue?: { active?: number, queued?: number, limit?: number }|null }} p
306
334
  */
307
- export function formatTurnProgress({ phase, elapsedMs, modelRounds = 0, toolCalls = 0, toolExecuted = 0, contentChars = 0, ttftMs = null, pendingTool = null, tokensPerSec = null, subagent = null }) {
335
+ export function formatTurnProgress({ phase, elapsedMs, modelRounds = 0, toolCalls = 0, toolExecuted = 0, contentChars = 0, ttftMs = null, pendingTool = null, tokensPerSec = null, subagent = null, localQueue = null }) {
308
336
  const s = Math.max(0, Math.round(Number(elapsedMs) / 1000));
309
337
  const parts = [
310
338
  `已跑 ${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`,
@@ -316,6 +344,15 @@ export function formatTurnProgress({ phase, elapsedMs, modelRounds = 0, toolCall
316
344
  if (tokensPerSec != null) parts.push(`≈${Number(tokensPerSec).toFixed(1)} tok/s`);
317
345
  parts.push(`已收正文=${contentChars} 字`);
318
346
  if (pendingTool) parts.push(`正在等待工具 ${pendingTool.name}(已 ${Math.round(Number(pendingTool.ms) / 1000)}s)`);
347
+ // v0.6.15(§3.59):本地端点因内存压力串行化时,进度行必须能看出来——
348
+ // 否则"在排队"与"引擎卡住"在事后日志里完全同形(真机取证最缺的就是这一行)。
349
+ if (localQueue) {
350
+ parts.push(
351
+ `本地端点已串行化(防内存压力):等待 ${Math.max(0, Number(localQueue.active) || 0)} 个在途请求` +
352
+ (Number(localQueue.queued) > 0 ? `、${Number(localQueue.queued)} 个排队` : '') +
353
+ `,并发上限 ${Number(localQueue.limit) || 1}`
354
+ );
355
+ }
319
356
  // v0.6.13(问题 3):等 `task` 子代理时必须能看出"这个子任务跑到哪一步"——
320
357
  // 否则"等子代理 620s"与"卡死 620s"在日志上完全同形(真机现场正是如此)。
321
358
  if (subagent) {
@@ -753,6 +790,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
753
790
  session: sessionRef?.name ?? null,
754
791
  cwd: workingDir,
755
792
  permission: cfg.permission ?? permission?.mode ?? null,
793
+ // v0.6.17 §3.64:来源由入口(WebUI/CLI)算好随 cfg 传入;agent 只透传,不猜。
794
+ // 后台任务/REPL 内切换预设等未传来源的路径写 null(来源未知),绝不冒充 config。
795
+ permissionSource: cfg.permissionSource ?? null,
756
796
  preset: preset?.name ?? cfg.preset ?? null,
757
797
  // v0.6.7(报告二 P3-1):activeCtx 的形状是 { mounted, warnings, promptSections, constraints, toolCount },
758
798
  // **没有 `.packs` 键** —— 这里此前恒为 [],账本里"本次运行挂了哪些 Pack"永远记空。
@@ -859,6 +899,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
859
899
  // 界面/日志据此显示"在等哪个子任务、它跑到第几轮 / 几次工具调用"
860
900
  subagent: null,
861
901
  forecast: null,
902
+ // v0.6.15(§3.59):本地端点串行化快照(排队时由 provider 的 onQueueWait 填入)
903
+ localQueue: null,
904
+ localQueueNoticed: false,
862
905
  noProgressMs,
863
906
  lastProgressAt: Date.now(),
864
907
  progressReason: '回合开始',
@@ -1278,6 +1321,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1278
1321
  try {
1279
1322
  let promptTokens = 0;
1280
1323
  for (const m of sanitized) promptTokens += messageTokens(m, count);
1324
+ // v0.6.15(§3.59 第 3 条):本地端点是否已有在途/排队请求(父回合、子代理、预检共用同一队列)。
1325
+ // 这里读的是**进程级闸门快照**(provider.config.baseUrl),不是猜——云端端点恒为 0。
1326
+ let gate = { active: 0, queued: 0, limit: 1 };
1327
+ try {
1328
+ if (caps.isLocal) gate = localGateSnapshot(provider?.config?.baseUrl);
1329
+ } catch {}
1281
1330
  const f = prefillForecast({
1282
1331
  promptTokens,
1283
1332
  contextWindow: caps.contextWindow,
@@ -1285,6 +1334,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1285
1334
  firstTokenMs: Number(cfg?.timeout?.firstTokenMs) > 0 ? Number(cfg.timeout.firstTokenMs) : effFirstTokenMs,
1286
1335
  budget,
1287
1336
  probe: cfg?.endpointProbe || null,
1337
+ localBusy: Boolean(caps.isLocal && (gate.active > 0 || gate.queued > 0)),
1338
+ localActive: gate.active,
1339
+ localQueued: gate.queued,
1340
+ localConcurrency: localConcurrencyLimit(cfg),
1288
1341
  });
1289
1342
  turnProgress.forecast = f;
1290
1343
  // 首次请求一定说;之后只在命中告警线时说(否则每步一条横幅会刷屏)
@@ -1309,6 +1362,31 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1309
1362
  // MiniMax P0:仅当 activeModel 声明支持 reasoning 才发送(否则 400 终止回合);'off' 显式禁用例外
1310
1363
  reasoningEffort: modelPreset(activeModel)?.supportsReasoning || reasoningEffort === 'off' ? reasoningEffort : undefined,
1311
1364
  signal: ac.signal,
1365
+ // v0.6.15(§3.59 第 1 条):本地端点串行化必须**在界面上可见**——
1366
+ // 排队时状态条/进度行写"排队等待本地端点(已串行化防内存压力)",
1367
+ // 拿到名额后恢复"发请求(等首帧)"(onDelta 之后会覆盖成"解码中")。
1368
+ onQueueWait(/** @type {any} */ ev) {
1369
+ if (!ev) return;
1370
+ if (ev.waiting) {
1371
+ turnProgress.localQueue = { active: ev.active, queued: ev.queued, limit: ev.limit };
1372
+ turnProgress.phase = `排队等待本地端点(已串行化防内存压力:${ev.active} 个在途)`;
1373
+ if (!turnProgress.localQueueNoticed) {
1374
+ turnProgress.localQueueNoticed = true;
1375
+ try {
1376
+ io.print(
1377
+ style(
1378
+ `⏳ 本地端点串行化:等待 ${ev.active} 个在途请求(并发上限 ${ev.limit})——` +
1379
+ `本地模型内存压力,父回合与子代理不再同时 prefill`,
1380
+ C.dim
1381
+ )
1382
+ );
1383
+ } catch {}
1384
+ }
1385
+ } else {
1386
+ turnProgress.localQueue = null;
1387
+ if (String(turnProgress.phase || '').startsWith('排队等待本地端点')) turnProgress.phase = '发请求(等首帧)';
1388
+ }
1389
+ },
1312
1390
  onDelta(/** @type {any} */ d) {
1313
1391
  io.stopSpinner();
1314
1392
  // v0.6.13(A):帧/字符计数 + 首帧时延(TTFT)——本地模型最关键的指标
@@ -1362,7 +1440,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1362
1440
  // MacBook 本地 507 memory_refusal 根因(v0.4.5):服务端内存拒绝不是「空输出」——
1363
1441
  // 直接终结合合并透出降级提示,不计入空轮、不注入续写重试(内存未释放必再 507,空烧请求)。
1364
1442
  const e = /** @type {any} */ (err);
1365
- if (e?.status === 507 || /memory_refusal|内存不足|内存拒绝/i.test(String(e?.message || ''))) {
1443
+ // v0.6.15(§3.59 第 2 条):`engine shed its caches and stays up` / `allocator wall`
1444
+ // 说明引擎**活着**(自己丢缓存后继续服务)——不走这条"507 旧文案"(那里建议重启引擎,
1445
+ // 对活着但内存吃紧的引擎是错误处置);交给下面 describeUpstreamError 的独立分型。
1446
+ const engineShedCaches = /engine shed its caches and stays up|allocator wall/i.test(String(e?.message || ''));
1447
+ if (!engineShedCaches && (e?.status === 507 || /memory_refusal|内存不足|内存拒绝/i.test(String(e?.message || '')))) {
1366
1448
  stripOrphanCalls();
1367
1449
  markOutcome('done');
1368
1450
  return {
@@ -1515,22 +1597,18 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1515
1597
  ...extra,
1516
1598
  });
1517
1599
  let args = /** @type {any} */ (null);
1518
- try {
1519
- args = JSON.parse(tc.function?.arguments || '{}');
1520
- } catch {
1600
+ // 参数解析/容错/归一化单源在 tools-flow.js 的 parseToolArgs()(v0.6.17,P1-1 第四刀):
1601
+ // 本函数只返回判据,下面三条副作用(渲染拒绝 / 审计 / 回填分块指引)仍按原顺序执行。
1602
+ const parsedArgs = parseToolArgs(tc.function?.arguments);
1603
+ if (!parsedArgs.ok) {
1521
1604
  // 参数 JSON 解析失败:回填错误给模型,不拿空参数去执行工具
1522
- io.renderToolDenied(name, {}, '参数解析失败(输出超限被截断?建议分块)');
1523
- if (auditOn) auditEntry({ denied: true, reason: '参数解析失败' });
1605
+ io.renderToolDenied(name, {}, parsedArgs.deniedReason);
1606
+ if (auditOn) auditEntry({ denied: true, reason: parsedArgs.auditReason });
1524
1607
  // 审计(长生成截断循环):给出明确分块指引,避免模型反复用单个超大 write 撞输出上限
1525
- messages.push({
1526
- role: 'tool',
1527
- tool_call_id: tc.id,
1528
- content:
1529
- '工具参数 JSON 解析失败(大概率是输出超过模型单次上限被截断)。请把内容拆分成多个较小文件或多次调用逐步写入:单个 write 的参数总长控制在 6000 字符以内;先写核心骨架,再逐文件补充。',
1530
- });
1608
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: parsedArgs.message });
1531
1609
  return null;
1532
1610
  }
1533
- if (!args || typeof args !== 'object' || Array.isArray(args)) args = {};
1611
+ args = parsedArgs.args;
1534
1612
 
1535
1613
  // PreToolUse 钩子
1536
1614
  const hook = await hooks.pre(name, args);
package/src/cli.js CHANGED
@@ -403,7 +403,16 @@ async function main() {
403
403
  }
404
404
  }
405
405
  // agent 使用的配置 = cfg + 预设 overlay(presetTools/permission/参数按预设生效,cfg 本体保持干净)
406
- const agentCfg = Object.keys(presetOverlay).length ? { ...cfg, ...presetOverlay } : cfg;
406
+ const agentCfgBase = Object.keys(presetOverlay).length ? { ...cfg, ...presetOverlay } : cfg;
407
+ // v0.6.17 §3.64:本进程的权限来源随 cfg 进账本(`run.start.permissionSource`)。
408
+ // 顺序与生效值一致:预设 overlay 里有 permission > config.json 的 permission > 内置缺省。
409
+ // 注意**不写回 cfg**(agentCfg 是副本):repl 的 saveConfig(cfg) 绝不能把来源字段持久化。
410
+ const permissionSource = Object.prototype.hasOwnProperty.call(presetOverlay, 'permission')
411
+ ? 'preset'
412
+ : cfg.permission !== undefined && cfg.permission !== null
413
+ ? 'config'
414
+ : 'default';
415
+ const agentCfg = { ...agentCfgBase, permissionSource };
407
416
 
408
417
  const pc0 = resolveProviderConfig(cfg, modelName);
409
418
  // v0.6.0 C4:内网/本机端点(vLLM、Ollama、OneAPI 等)通常不校验密钥,很多也不发放密钥。
package/src/ledger.js CHANGED
@@ -36,6 +36,21 @@ import { redactSecrets, redactSensitive } from './redact.js';
36
36
  * 不是「新读者多了字段」。
37
37
  */
38
38
  export const LEDGER_VERSION = 1;
39
+ /**
40
+ * `run.start.permissionSource` 的合法取值(v0.6.17 §3.64)。
41
+ *
42
+ * 为什么只增字段不动 `LEDGER_VERSION`:`permission` 仍然是**生效值**,本字段只是补上
43
+ * 「这个生效值是谁定的」——旧读者按原样忽略它,旧账本(没有它)也照常校验通过。
44
+ * 一次"来源不可见"的坑(界面选了自动、却被只读预设压着跑)迫使我们把来源写进账本:
45
+ * 事后自查时,`permission:"auto"` 单看无法区分"用户显式选的"与"config 里本来就是 auto"。
46
+ *
47
+ * - `explicit`:用户/调用方在本回合显式选择(WebUI 的权限档、CLI 的显式参数);
48
+ * - `preset`:由预设声明的 permission 生效(反提权判定已通过);
49
+ * - `config`:来自 `config.json` 的 `permission`;
50
+ * - `cli`:后台/调度任务由 CLI 参数指定(`mingdao run --permission ...`);
51
+ * - `default`:以上都没有,落到内置缺省(`ask`)。
52
+ */
53
+ export const PERMISSION_SOURCES = Object.freeze(['explicit', 'preset', 'config', 'cli', 'default']);
39
54
  /** 默认保留最近多少次运行(超出按 mtime 删最旧) */
40
55
  const DEFAULT_MAX_RUNS = 200;
41
56
  const GENESIS = '0'.repeat(16);
@@ -560,6 +575,10 @@ export function createLedger(runId, { enabled = true, maxRuns = DEFAULT_MAX_RUNS
560
575
  session: f.session ?? null,
561
576
  cwd: f.cwd ?? null,
562
577
  permission: f.permission ?? null,
578
+ // v0.6.17 §3.64:生效值(permission)与来源分开记——只记生效值无法回答
579
+ // "这是用户选的、预设压的,还是 config 默认的"。非法/缺失一律 null(诚实:来源未知),
580
+ // 不猜、不落成 'config'(猜错比缺失更难自查)。
581
+ permissionSource: PERMISSION_SOURCES.includes(String(f.permissionSource)) ? String(f.permissionSource) : null,
563
582
  preset: f.preset ?? null,
564
583
  packs: Array.isArray(f.packs) ? f.packs : [],
565
584
  });
@@ -876,6 +895,9 @@ export function exportRun(/** @type {any} */ runId, { format = 'json' } = {}) {
876
895
  lines.push(`# 执行账本 ${runId}`);
877
896
  lines.push('');
878
897
  lines.push(`- 模型:${start?.model ?? '—'} · 会话:${start?.session ?? '—'} · 权限:${start?.permission ?? '—'}`);
898
+ // v0.6.17 §3.64:来源是**可选**字段——旧账本没有它,这一行就整行不出现(输出契约不变);
899
+ // 新账本才有,且只增行、不改上面那行「权限:…」的既有形状。
900
+ if (start?.permissionSource) lines.push(`- 权限来源:${start.permissionSource}(explicit=显式选择 / preset=预设 / config=配置 / cli=命令行 / default=内置缺省)`);
879
901
  lines.push(`- 开始:${start?.at ? new Date(start.at).toISOString() : '—'}`);
880
902
  lines.push(`- 结束:${end ? `${end.status}(${end.ms}ms,${end.steps} 步)` : '未结束'}`);
881
903
  lines.push(`- 费用:${end?.priced ? `¥${Number(end.yuanTotal ?? 0).toFixed(4)}` : '**无法估算**(模型无价,不是 0 元)'}`);
@@ -12,7 +12,7 @@ import { mingdaoHome, ensureHome } from './config.js';
12
12
  import { PROVIDERS, MODELS } from './models.js';
13
13
  import { getStoredKey } from './credentials.js';
14
14
  import { isLocalBaseUrl, resolveModelCaps } from './model-caps.js';
15
- import { resolveProviderConfig } from './providers/index.js';
15
+ import { resolveProviderConfig, acquireLocalSlot, localQueueTimeoutMs } from './providers/index.js';
16
16
 
17
17
  const TTL_MS = 60 * 60 * 1000;
18
18
 
@@ -355,19 +355,43 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
355
355
  * 为什么(v0.6.13 修复):探针是 `stream: true`,`fetch` 在**响应头**到达时就 resolve;此前
356
356
  * clearTimeout 就在那时执行,于是"引擎回了头、卡在 prefill"这种情况**没有任何超时**
357
357
  * (桩复现:给 1.5s 超时,6s 后探针仍在跑),而它在回合发起之前被 await —— 把回合发起拖死。
358
+ *
359
+ * v0.6.15(§3.59):**预检也必须过本地端点并发闸门**——探针打的是与主请求同一个
360
+ * `/chat/completions`,父子回合长 prefill 的同时再插一个探针请求,抢的是同一份内存。
361
+ * 排队发生在计时器启动**之前**(排队是内核自己的调度,不能报成"引擎 1.5s 没给首帧"),
362
+ * 但排队本身也有上限(取 min(config.localQueueTimeoutMs, 本探针剩余预算));
363
+ * 名额在 cleanup(读完首帧/失败)时释放,覆盖整个流式读取窗口。
358
364
  */
359
365
  const open = async (/** @type {any} */ url, /** @type {any} */ init, /** @type {any} */ ms) => {
360
366
  const ac = new AbortController();
361
367
  const terr = /** @type {any} */ (new Error(`预检超时(${(ms / 1000).toFixed(1)}s)`));
362
368
  terr.probeTimeout = true;
363
369
  terr.probeTimeoutMs = ms;
370
+ let releaseLocal = /** @type {null | (() => void)} */ (null);
371
+ try {
372
+ releaseLocal = await acquireLocalSlot(base, {
373
+ cfg,
374
+ signal: ac.signal,
375
+ endpoint: `预检 ${endpointLabel(base) || base}`,
376
+ queueTimeoutMs: Math.max(1, Math.min(localQueueTimeoutMs(cfg), left())),
377
+ });
378
+ } catch (e) {
379
+ // 排队超时/中断也归入探针超时语义(端口是通的,只是本地端点正被其他请求占着)
380
+ if (e && typeof e === 'object' && /排队超时/.test(String(/** @type {any} */ (e).message || ''))) {
381
+ /** @type {any} */ (e).probeTimeout = true;
382
+ }
383
+ throw e;
384
+ }
364
385
  const t = setTimeout(() => ac.abort(terr), ms);
365
- const cleanup = () => clearTimeout(t);
386
+ const cleanup = () => {
387
+ clearTimeout(t);
388
+ try { releaseLocal?.(); } catch {}
389
+ };
366
390
  try {
367
391
  const res = await fetchImplOr(doFetch, url, { ...init, signal: ac.signal });
368
392
  return { res, cleanup, timeoutErr: terr };
369
393
  } catch (e) {
370
- cleanup(); // 失败路径立刻清计时器(成功路径由调用方读完后清)
394
+ cleanup(); // 失败路径立刻清计时器并释放名额(成功路径由调用方读完后清)
371
395
  throw e;
372
396
  }
373
397
  };
@@ -17,6 +17,241 @@ import { mingdaoHome } from '../config.js';
17
17
  import { resolveApiKey, getStoredKey } from '../credentials.js';
18
18
  import { isLocalBaseUrl } from '../model-caps.js';
19
19
 
20
+ // ---------------------------------------------------------------------------
21
+ // 本地端点并发闸门(v0.6.15,登记 §3.59 四条待办之首)
22
+ //
23
+ // 真机证据(v0.6.14,macOS,`mtplx-qwen38-27b-optimized-quality` @ 8081):
24
+ // 长任务中途被引擎拒绝——
25
+ // insufficient memory: the request exceeded available GPU memory
26
+ // (sustained critical memory pressure during prefill; aborted before the allocator wall).
27
+ // The engine shed its caches and stays up; this request failed.
28
+ // 同一错误此前在 60091 的 35B 上也出现过 → **不是模型问题,是本机内存压力**;
29
+ // 观测到该回合此前有子代理在跑(13 轮 / 17 次调用)→ 父回合 + 子代理同时向**同一本地引擎**
30
+ // 发请求是内存压力的主要来源(两个大 prefill 各自要一份 KV cache)。
31
+ //
32
+ // 修法:对**同一 baseUrl** 的本地端点在途请求做**串行化/限量**,默认 1 并发。
33
+ // 覆盖三类来源(都必须过同一道闸门,否则等于没修):
34
+ // ① 父回合的 provider.chat;
35
+ // ② 子代理(同一 provider 实例/同一 baseUrl,天然共享本模块的进程级队列);
36
+ // ③ 端点预检(model-discovery.js 的探针也打同一个 /chat/completions,抢的是同一份内存)。
37
+ // 云端端点**不受影响**:`isLocalBaseUrl` 为假时直接放行(不排队、不记日志、不加延迟)。
38
+ //
39
+ // 三条硬约束:
40
+ // · **不许忙等**:Promise 队列 + 释放即移交名额;
41
+ // · **排队也有上限**:`config.localQueueTimeoutMs`(默认 30 分钟),超时给**可见错误**,
42
+ // 不会让第二个回合永远挂着(真机最怕的正是"看起来在跑、其实永远排不到");
43
+ // · **日志留痕**:入队时记一行「本地端点串行化:等待 N 个在途请求」,并回调 onWait
44
+ // 让 agent/WebUI 把「已串行化(防内存压力)」摆到进度行上。
45
+
46
+ /** 默认并发上限:本地引擎通常是单进程/单 slot,1 是最安全的默认值。 */
47
+ export const DEFAULT_LOCAL_CONCURRENCY = 1;
48
+ /** 默认排队等待上限:与本地 `timeout.totalMs`(30 分钟)同量级,避免死等。 */
49
+ export const DEFAULT_LOCAL_QUEUE_TIMEOUT_MS = 30 * 60 * 1000;
50
+
51
+ /**
52
+ * 归一化 baseUrl(去掉尾部斜杠):同一端点的不同写法必须命中同一条队列。
53
+ * @param {any} baseUrl
54
+ */
55
+ export function normalizeLocalBaseUrl(/** @type {any} */ baseUrl) {
56
+ return String(baseUrl || '').trim().replace(/\/+$/, '');
57
+ }
58
+
59
+ /**
60
+ * 本地端点并发上限(纯函数,便于断言/变异):`config.localConcurrency`。
61
+ * · 未配置 / 非法 / 负数 → 默认 1(fail-safe:宁可串行化,也不要再撞内存墙);
62
+ * · **0 → 不限**(显式退出串行化,用户明确知道自己在意延迟而不是内存);
63
+ * · 正整数 → 该值(上限 16,防手滑写成 999 等于没限制)。
64
+ * @param {any} cfg
65
+ */
66
+ export function localConcurrencyLimit(/** @type {any} */ cfg) {
67
+ const raw = cfg?.localConcurrency;
68
+ if (raw === undefined || raw === null || raw === '') return DEFAULT_LOCAL_CONCURRENCY;
69
+ const n = Number(raw);
70
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_LOCAL_CONCURRENCY;
71
+ return Math.min(16, Math.floor(n));
72
+ }
73
+
74
+ /**
75
+ * 排队等待上限(纯函数):`config.localQueueTimeoutMs`,未配置/非法 → 30 分钟。
76
+ * 0/负数不表示"无限等",而是回落默认(死等是这轮要消灭的东西)。
77
+ * @param {any} cfg
78
+ */
79
+ export function localQueueTimeoutMs(/** @type {any} */ cfg) {
80
+ const n = Number(cfg?.localQueueTimeoutMs);
81
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_LOCAL_QUEUE_TIMEOUT_MS;
82
+ return Math.min(n, 24 * 60 * 60 * 1000);
83
+ }
84
+
85
+ /** 一条给人看的入队日志(纯函数,日志/UI 共用同一份口径)。 @param {any} ev */
86
+ export function localQueueEventText(/** @type {any} */ ev) {
87
+ const endpoint = String(ev?.endpoint || ev?.baseUrl || '本地端点');
88
+ const active = Number(ev?.active) || 0;
89
+ const queued = Number(ev?.queued) || 0;
90
+ const limit = Number(ev?.limit) || DEFAULT_LOCAL_CONCURRENCY;
91
+ return (
92
+ `本地端点串行化:等待 ${active} 个在途请求(${endpoint},并发上限 ${limit}` +
93
+ `${queued > 1 ? `,排在第 ${queued} 位` : ''})——本地模型内存压力,避免父子回合同时 prefill`
94
+ );
95
+ }
96
+
97
+ /** @type {((ev: any) => void)|null} */
98
+ let queueObserver = null;
99
+ /**
100
+ * 注册串行化事件观察者(WebUI 启动时接到 srvlog;不注册则写 console.error)。
101
+ * @param {((ev: any) => void)|null} fn
102
+ */
103
+ export function setLocalQueueObserver(/** @type {any} */ fn) {
104
+ queueObserver = typeof fn === 'function' ? fn : null;
105
+ }
106
+
107
+ function emit(/** @type {any} */ ev) {
108
+ try {
109
+ if (queueObserver) queueObserver(ev);
110
+ else console.error(`[MingDao] ${ev.text}`);
111
+ } catch {
112
+ /* 日志失败不得影响请求 */
113
+ }
114
+ }
115
+
116
+ /** @type {Map<string, { baseUrl: string, limit: number, active: number, waiters: any[] }>} */
117
+ const localGates = new Map();
118
+
119
+ /** @param {string} baseUrl @param {number} limit */
120
+ function localGateFor(baseUrl, limit) {
121
+ let g = localGates.get(baseUrl);
122
+ if (!g) {
123
+ g = { baseUrl, limit, active: 0, waiters: [] };
124
+ localGates.set(baseUrl, g);
125
+ }
126
+ g.limit = limit;
127
+ return g;
128
+ }
129
+
130
+ /** 端点当前状态(纯快照,供预告/进度行/断言用;不参与调度)。 @param {any} baseUrl */
131
+ export function localGateSnapshot(/** @type {any} */ baseUrl) {
132
+ const key = normalizeLocalBaseUrl(baseUrl);
133
+ const g = localGates.get(key);
134
+ if (!g) return { baseUrl: key, active: 0, queued: 0, limit: DEFAULT_LOCAL_CONCURRENCY, known: false };
135
+ return { baseUrl: key, active: g.active, queued: g.waiters.length, limit: g.limit, known: true };
136
+ }
137
+
138
+ /** 该端点现在是否已有在途/排队请求(纯查询;云端恒 false)。 @param {any} baseUrl */
139
+ export function localGateBusy(/** @type {any} */ baseUrl) {
140
+ const snap = localGateSnapshot(baseUrl);
141
+ return Boolean(snap.known && (snap.active > 0 || snap.queued > 0));
142
+ }
143
+
144
+ /** 取得名额后的释放函数:把名额**直接移交**给下一个等待者(active 不变),否则 active--。 */
145
+ function makeLocalRelease(/** @type {any} */ g) {
146
+ let done = false;
147
+ return () => {
148
+ if (done) return;
149
+ done = true;
150
+ const next = g.waiters.shift();
151
+ if (next) {
152
+ // 名额移交:active 不减,等待者的 grant 负责 resolve(不改变 active)
153
+ next.grant();
154
+ return;
155
+ }
156
+ g.active = Math.max(0, g.active - 1);
157
+ if (g.active === 0 && g.waiters.length === 0 && localGates.get(g.baseUrl) === g) localGates.delete(g.baseUrl);
158
+ };
159
+ }
160
+
161
+ /**
162
+ * 取得一个本地端点执行名额(云端端点直接返回空释放函数)。
163
+ *
164
+ * 返回的 release **必须**在请求真正结束后调用(流读完、finally 里),否则后续请求会
165
+ * 永远排队——这正是"不能用忙等、也不许忘记释放"的接口约束。
166
+ *
167
+ * @param {any} baseUrl
168
+ * @param {{ cfg?: any, signal?: any, onWait?: (ev: any) => void, queueTimeoutMs?: number,
169
+ * endpoint?: string, label?: string }} [opts]
170
+ * @returns {Promise<() => void>}
171
+ */
172
+ export async function acquireLocalSlot(/** @type {any} */ baseUrl, opts = {}) {
173
+ const key = normalizeLocalBaseUrl(baseUrl);
174
+ // 云端端点不受影响:不排队、不记日志、不增加任何延迟(需求 1 的硬边界)。
175
+ if (!isLocalBaseUrl(key)) return () => {};
176
+ const limit = localConcurrencyLimit(opts.cfg);
177
+ if (limit === 0) return () => {}; // 显式退出串行化
178
+ const waitMs = Number(opts.queueTimeoutMs) > 0 ? Number(opts.queueTimeoutMs) : localQueueTimeoutMs(opts.cfg);
179
+ const g = localGateFor(key, limit);
180
+
181
+ if (g.active < g.limit) {
182
+ g.active += 1;
183
+ return makeLocalRelease(g);
184
+ }
185
+
186
+ // 需要排队:先留痕(日志/UI),再把自己挂到 Promise 队列上。
187
+ const endpoint = String(opts.endpoint || key);
188
+ const queued = g.waiters.length + 1;
189
+ const ev = /** @type {any} */ ({ type: 'wait', baseUrl: key, endpoint, active: g.active, queued, limit: g.limit, label: opts.label || '' });
190
+ ev.text = localQueueEventText(ev);
191
+ emit(ev);
192
+ try {
193
+ opts.onWait?.(ev);
194
+ } catch {}
195
+
196
+ return await new Promise((resolve, reject) => {
197
+ /** @type {any} */
198
+ const waiter = { settled: false, grant: null, timer: null, onAbort: null, signal: opts.signal || null };
199
+ const settle = (/** @type {boolean} */ ok, /** @type {any} */ value) => {
200
+ if (waiter.settled) return;
201
+ waiter.settled = true;
202
+ if (waiter.timer) clearTimeout(waiter.timer);
203
+ try {
204
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener('abort', waiter.onAbort);
205
+ } catch {}
206
+ const at = g.waiters.indexOf(waiter);
207
+ if (at >= 0) g.waiters.splice(at, 1);
208
+ if (ok) resolve(value);
209
+ else reject(value);
210
+ };
211
+ waiter.grant = () => {
212
+ if (waiter.settled) return;
213
+ waiter.settled = true;
214
+ if (waiter.timer) clearTimeout(waiter.timer);
215
+ try {
216
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener('abort', waiter.onAbort);
217
+ } catch {}
218
+ // 名额已从上一个持有者移交过来:active 保持不变
219
+ try {
220
+ opts.onWait?.({ ...ev, waiting: false, active: g.active, queued: Math.max(0, g.waiters.length), limit: g.limit });
221
+ } catch {}
222
+ resolve(makeLocalRelease(g));
223
+ };
224
+ waiter.timer = setTimeout(() => {
225
+ const err = /** @type {any} */ (
226
+ new Error(
227
+ `本地端点排队超时(等待 ${(waitMs / 1000).toFixed(1)}s,仍有 ${g.active} 个在途请求,并发上限 ${g.limit}):` +
228
+ `为避免内存压力已在本机端点串行化——可调大 config.localQueueTimeoutMs,或减少子代理/并行会话。`
229
+ )
230
+ );
231
+ err.code = 'LOCAL_QUEUE_TIMEOUT';
232
+ err.status = 0;
233
+ err.localEndpoint = key;
234
+ settle(false, err);
235
+ }, waitMs);
236
+ waiter.onAbort = () => settle(false, opts.signal?.reason ?? new Error('已中断(本地端点排队中)'));
237
+ if (opts.signal) {
238
+ if (opts.signal.aborted) {
239
+ waiter.onAbort();
240
+ return;
241
+ }
242
+ try {
243
+ opts.signal.addEventListener('abort', waiter.onAbort, { once: true });
244
+ } catch {}
245
+ }
246
+ g.waiters.push(waiter);
247
+ });
248
+ }
249
+
250
+ /** 仅供测试:清空全部队列(不影响已取得名额的释放函数)。 */
251
+ export function __resetLocalGates() {
252
+ localGates.clear();
253
+ }
254
+
20
255
  // v0.6.3(下游 Dify 工作流反馈):`customModels` 的条目其实是**两种东西**,此前混为一谈——
21
256
  // · **声明式端点**:写了 baseUrl / envKey / apiKey / headers 等传输字段 → 确实是"自定义
22
257
  // OpenAI 兼容端点",优先于内置预设(原行为,不变);
@@ -254,40 +489,58 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
254
489
  }
255
490
  const ac = new AbortController();
256
491
  currentAc = ac;
257
- let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时(首 token/流式空闲)
258
- // 首 token 等待:prefill 阶段无任何帧到达即断(覆盖长上下文慢 prefill)
259
- let firstTokenTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (setTimeout(() => {
260
- timedOut = true;
261
- ac.abort(new Error(`首 token 等待超限(${Math.round(firstTokenMs / 1000)}s,本地模型长上下文 prefill 可能很慢)——可调大 config.timeout.firstTokenMs,或拆分任务/压缩上下文`));
262
- }, firstTokenMs));
263
- // 流式空闲:有帧后 120s 无新帧即断;每收到一帧重置
264
- let idleTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (null);
265
- const armIdle = () => {
266
- if (idleTimer) clearTimeout(idleTimer);
267
- idleTimer = setTimeout(() => {
268
- timedOut = true;
269
- ac.abort(new Error(`流式响应空闲超限(${Math.round(streamIdleMs / 1000)}s 无新数据)`));
270
- }, streamIdleMs);
271
- };
272
- const onActivity = () => {
273
- sawFrame = true;
274
- if (firstTokenTimer) {
275
- clearTimeout(firstTokenTimer);
276
- firstTokenTimer = null;
277
- }
278
- armIdle();
279
- };
280
- // 转发外部信号(用户 Ctrl+C 中断),避免被内部超时信号覆盖
492
+ // 转发外部信号(用户 Ctrl+C 中断),避免被内部超时信号覆盖。
493
+ // 必须在**排队之前**挂上:否则用户中断时请求仍卡在本地端点队列里。
281
494
  const onUserAbort = () => ac.abort(opts.signal?.reason);
282
495
  if (opts.signal?.aborted) onUserAbort();
283
496
  else opts.signal?.addEventListener('abort', onUserAbort, { once: true });
497
+ let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时(首 token/流式空闲)
498
+ let firstTokenTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (null);
499
+ let idleTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (null);
284
500
  let leaving = true; // 本次 attempt 是否退出(成功/不再重试);将重试则置 false,总量计时器保留覆盖下一 attempt
285
501
  // v0.6.3(BUG-028):**收到过任何一帧就不再重试**。chat 是**非幂等**的 POST:
286
502
  // 上游一旦开始生成按量计费,重试等于再买一次(用户只看到一份回答、账本却计两次)。
287
503
  // 判据取"有没有帧到达"而不是"有没有正文"——usage-only/角色帧同样说明请求已被受理。
288
504
  // 代价如实说明:流式空闲超时(已开始输出)不再重试,失败以错误形式回到用户手里,而不是悄悄多花一份钱。
289
505
  let sawFrame = false;
506
+ let releaseLocal = /** @type {null | (() => void)} */ (null);
290
507
  try {
508
+ // v0.6.15(§3.59):**本地端点并发闸门**——同一 baseUrl 的父回合 / 子代理 / 预检
509
+ // 共用一条进程级 Promise 队列(默认 1 并发)。真机 8081:父子回合同时 prefill 时
510
+ // 引擎报 "insufficient memory … engine shed its caches and stays up"。
511
+ // 必须在**首 token 计时之前**排队:排队是内核自己的调度,不是"引擎迟迟不出首帧"。
512
+ // 云端端点由 acquireLocalSlot 内部直接放行(不排队、不记日志、不加延迟)。
513
+ releaseLocal = await acquireLocalSlot(pc.baseUrl, {
514
+ cfg,
515
+ signal: ac.signal,
516
+ onWait: opts.onQueueWait,
517
+ endpoint: pc.name || pc.baseUrl,
518
+ label: modelName,
519
+ });
520
+ // 排队期间总量计时器可能已经到点:它会 abort 本 attempt 的 ac(见 totalTimer),
521
+ // 排队等待因此以「请求总时长超限」拒绝(acquireLocalSlot 透传 signal.reason);
522
+ // 这里**不再写一份重复的复查**——循环头那一处是 BUG-029 的单一判据(§119 钉住)。
523
+ // 首 token 等待:prefill 阶段无任何帧到达即断(覆盖长上下文慢 prefill)
524
+ firstTokenTimer = setTimeout(() => {
525
+ timedOut = true;
526
+ ac.abort(new Error(`首 token 等待超限(${Math.round(firstTokenMs / 1000)}s,本地模型长上下文 prefill 可能很慢)——可调大 config.timeout.firstTokenMs,或拆分任务/压缩上下文`));
527
+ }, firstTokenMs);
528
+ // 流式空闲:有帧后 120s 无新帧即断;每收到一帧重置
529
+ const armIdle = () => {
530
+ if (idleTimer) clearTimeout(idleTimer);
531
+ idleTimer = setTimeout(() => {
532
+ timedOut = true;
533
+ ac.abort(new Error(`流式响应空闲超限(${Math.round(streamIdleMs / 1000)}s 无新数据)`));
534
+ }, streamIdleMs);
535
+ };
536
+ const onActivity = () => {
537
+ sawFrame = true;
538
+ if (firstTokenTimer) {
539
+ clearTimeout(firstTokenTimer);
540
+ firstTokenTimer = null;
541
+ }
542
+ armIdle();
543
+ };
291
544
  return await openaiChat({
292
545
  ...opts,
293
546
  baseUrl: pc.baseUrl,
@@ -299,7 +552,9 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
299
552
  } catch (err) {
300
553
  // 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试。
301
554
  // 总量超时(totalExpired)覆盖整个序列,超了直接抛不再重试。
302
- const transient = !totalExpired && !sawFrame && ((timedOut && !opts.signal?.aborted) || isTransient(err));
555
+ // 排队超时(LOCAL_QUEUE_TIMEOUT)**不得**重试:重试只会再排一次队、把等待翻倍。
556
+ const queueTimeout = (/** @type {any} */ (err))?.code === 'LOCAL_QUEUE_TIMEOUT';
557
+ const transient = !totalExpired && !queueTimeout && !sawFrame && ((timedOut && !opts.signal?.aborted) || isTransient(err));
303
558
  if (!transient || attempt >= retries) throw err;
304
559
  leaving = false; // 将重试:本次不退出,总量计时器继续覆盖下一 attempt
305
560
  attempt += 1;
@@ -315,6 +570,8 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
315
570
  if (firstTokenTimer) clearTimeout(firstTokenTimer);
316
571
  if (idleTimer) clearTimeout(idleTimer);
317
572
  opts.signal?.removeEventListener('abort', onUserAbort);
573
+ // 名额必须在这里释放(成功/失败/超时/中断都走 finally),否则队列会永久卡住。
574
+ try { releaseLocal?.(); } catch {}
318
575
  }
319
576
  }
320
577
  },
package/src/tools-flow.js CHANGED
@@ -17,6 +17,33 @@
17
17
  // 回填之所以拆成"序列化 → 正文"两个函数,而不是合成一个大函数,正是为了**保持原来的求值顺序**:
18
18
  // 原来 `JSON.stringify(result)` 发生在 `checkPostTool()` **之前**(约束检查抛错/慢的差异都被保留),
19
19
  // agent.js 里 `const text = serializeToolResult(result);` 就是原来那一行,位置一字未动。
20
+ //
21
+ // v0.6.17(审计 P1-1 第四刀):在同一个模块上继续切**前置门最前端的纯判据**——工具参数解析
22
+ // `parseToolArgs(raw)`。第三刀刻意否决了"整个前置门"(hooks → 白名单 → 权限 → 约束确认的顺序与
23
+ // 短路、账本内容依赖门的结果),本刀只取门**之前**、零副作用的那一步:JSON 解析 + 容错 + 归一化 +
24
+ // 错误文本。`io.renderToolDenied` / 审计 / `messages.push` 三条副作用仍留在 `agent.js`,
25
+ // 由 `ok:false` 分支按原顺序执行——顺序与短路一字未动,只是把"判据"从 1500 行的 `runTurn` 里拿了出来。
26
+ //
27
+ // `parseToolArgs()` 的**行为清单**(抽取前逐条抄下来;每个分支与抽取前逐字同源):
28
+ // 1. `raw` 为**假值**(`undefined` / `null` / `''` / `0` / `false` / `NaN`)→ 按 `'{}'` 解析
29
+ // → `{ ok:true, args:{} }`(模型没给参数 = 空参数对象;不会把假值当 JSON 去解析);
30
+ // 2. `raw` 是**合法 JSON 对象**文本(`'{"path":"a.txt","limit":3}'`)→ 原样作为 `args`
31
+ // (不增删键、不做类型转换、不 trim 字段值);
32
+ // 3. `raw` 是合法 JSON 但**不是对象**——JSON 字面量 `'null'`、数组 `'[]'`、数字 `'42'`、
33
+ // 字符串 `'"s"'`、布尔 `'true'` → **归一化为 `{}`**(数组也算非对象:工具参数必须是对象);
34
+ // 4. `raw` 是空白串(`' '`)或**非法 JSON**(`'{bad'`)→ 解析失败终态:
35
+ // `{ ok:false, deniedReason:'参数解析失败(输出超限被截断?建议分块)',
36
+ // auditReason:'参数解析失败',
37
+ // message:'工具参数 JSON 解析失败(大概率是输出超过模型单次上限被截断)。请把内容拆分成多个
38
+ // 较小文件或多次调用逐步写入:单个 write 的参数总长控制在 6000 字符以内;先写核心
39
+ // 骨架,再逐文件补充。' }`(三段文案与抽取前逐字一致);
40
+ // 5. **不做**前后缀宽容:```` ```json\n{"a":1}\n``` ````、`'{"a":1} trailing'` 都是解析失败
41
+ // (用的是严格 `JSON.parse`,不是"从文本里抠一段 JSON");
42
+ // 6. **不做**长度上限:一个 20000 字符的合法参数照样解析成功;"6000 字符以内"只是失败文案里的
43
+ // 分块建议,**不是**解析器的闸门;
44
+ // 7. 非字符串 `raw` 走 `JSON.parse` 的隐式 `ToString`:`42` → `42` → 非对象 → `{}`;
45
+ // `{}` → `'[object Object]'` → 解析失败;解析器抛出的任何异常(含 `TypeError`)都归入第 4 条;
46
+ // 8. **纯函数**:不渲染、不审计、不写消息、不执行工具,也不读全局;同一输入永远同一输出。
20
47
 
21
48
  import { clampText } from './context.js';
22
49
 
@@ -93,14 +120,20 @@ export function visibleToolsFor(schemas, { readOnlyPhase = false, usedNames = ne
93
120
  * 1. `result` 是字符串 → **原样**使用(工具已经返回了给模型看的文本,不再包一层 JSON 引号);
94
121
  * 2. 否则 → `JSON.stringify(result)` 紧凑 JSON(评估 B3:嵌套结果省 10~20% 回填 token,
95
122
  * 且这些 token 下一轮按 prompt 重复计费);
96
- * 3. 不做任何兜底/异常处理:`JSON.stringify` 抛错(循环引用)照旧上抛,
97
- * `undefined`/函数仍返回 `undefined`(见 `toolResultMessage` 的第 1 条边界)。
123
+ * 3. 顶层 `null` / `undefined`(以及函数、符号——`JSON.stringify` 对它们也返回 `undefined`)
124
+ * → **空串**(v0.6.17 第四刀附带 B,§143 收口)。理由:顶层空值都表示"没有输出";
125
+ * 不归一化时 `undefined` 会经 `clampText` 的 `String()` 变成字面量 `'undefined'`、`null`
126
+ * 变成 `'null'`,模型会把这两段噪声当成工具输出。**嵌套**的 null/undefined 不受影响
127
+ * (仍在 JSON 里按标准语义保留);
128
+ * 4. 不做其它兜底/异常处理:`JSON.stringify` 抛错(循环引用、BigInt)照旧上抛。
98
129
  *
99
130
  * @param {any} result `dispatch()` / `mcp.call()` 的返回(多数是 `{ok, output|error}` 对象或 JSON 字符串)
100
- * @returns {string} 待回填文本(运行时可能是 `undefined`,与抽取前一致)
131
+ * @returns {string} 待回填文本(顶层空值 → `''`;其余与抽取前一致)
101
132
  */
102
133
  export function serializeToolResult(result) {
103
- return typeof result === 'string' ? result : JSON.stringify(result);
134
+ if (typeof result === 'string') return result;
135
+ const text = JSON.stringify(result);
136
+ return text === undefined || text === 'null' ? '' : text;
104
137
  }
105
138
 
106
139
  /**
@@ -110,8 +143,9 @@ export function serializeToolResult(result) {
110
143
  * **行为清单**:
111
144
  * 1. 截断:`clampText(text, cap)`——`cap` 是 `toolResultCap`(按模型窗口 1/16,2000~20000 字);
112
145
  * 超长时尾部附 `…[输出过长已截断,原文共 N 字符]`。传 `undefined` 时用 `clampText` 自己的默认值。
113
- * 边界(与抽取前一致,本节只负责钉住、不负责改):`text` 为 `undefined` 时 `clampText` 内部
114
- * `String()` 得到字符串 `'undefined'`;
146
+ * 边界:本函数的**契约**是 `text` 已经过 `serializeToolResult()`(顶层空值已是空串);
147
+ * 若调用方违约直接传 `undefined`,`clampText` 的 `String()` 仍会得到字面量 `'undefined'`——
148
+ * 这是调用方违约的边界,不是回填路径(agent.js 唯一调用点走的是序列化后的文本)。
115
149
  * 2. 复用前缀:`cached` 为真(同回合相同参数的回调命中只读去重缓存)→ 正文前缀
116
150
  * `(与同回合相同调用结果一致,已复用)\n`,**且该前缀不计入 `cap`**(先截断、后加前缀);
117
151
  * 3. 领域约束(PostToolUse,v0.5.0 A3②):`post.rejected` 为真 → 正文**整段替换**为
@@ -129,3 +163,55 @@ export function toolResultMessage(text, { cached = false, cap = undefined, post
129
163
  const body = post?.rejected ? `【领域约束】${post.reason}` : text;
130
164
  return prefix + clampText(body, cap);
131
165
  }
166
+
167
+ /** `parseToolArgs()` 失败时的三段文案(与抽取前 `agent.js` 里的三处字面量逐字一致)。 */
168
+ const TOOL_ARGS_PARSE_DENIED = '参数解析失败(输出超限被截断?建议分块)';
169
+ const TOOL_ARGS_PARSE_AUDIT = '参数解析失败';
170
+ const TOOL_ARGS_PARSE_MESSAGE =
171
+ '工具参数 JSON 解析失败(大概率是输出超过模型单次上限被截断)。请把内容拆分成多个较小文件或多次调用逐步写入:单个 write 的参数总长控制在 6000 字符以内;先写核心骨架,再逐文件补充。';
172
+
173
+ /**
174
+ * 工具调用参数的**解析 / 容错 / 归一化**(v0.6.17,审计 P1-1 第四刀——前置门最前端的纯判据)。
175
+ *
176
+ * 抽取前是 `agent.js` 的 `prepTool()` 里这一段(`JSON.parse` + `catch` + 非对象归一化):
177
+ * ```js
178
+ * let args = null;
179
+ * try {
180
+ * args = JSON.parse(tc.function?.arguments || '{}');
181
+ * } catch {
182
+ * io.renderToolDenied(name, {}, '参数解析失败(输出超限被截断?建议分块)');
183
+ * if (auditOn) auditEntry({ denied: true, reason: '参数解析失败' });
184
+ * messages.push({ role: 'tool', tool_call_id: tc.id, content: '工具参数 JSON 解析失败(…)' });
185
+ * return null;
186
+ * }
187
+ * if (!args || typeof args !== 'object' || Array.isArray(args)) args = {};
188
+ * ```
189
+ * 本函数只接管**判据**;上面那三条副作用仍留在 `agent.js` 的 `ok:false` 分支里,顺序与短路不变。
190
+ *
191
+ * 行为清单(逐条与抽取前一致;§143 逐条钉死):
192
+ * 1. `raw` 为假值(`undefined` / `null` / `''` / `0` / `false` / `NaN`)→ 按 `'{}'` 解析
193
+ * → `{ ok:true, args:{} }`;
194
+ * 2. `raw` 是合法 JSON 对象文本 → 原样作为 `args`(不增删键、不做类型转换);
195
+ * 3. `raw` 是合法 JSON 但非对象(`'null'` / `'[]'` / `'42'` / `'"s"'` / `'true'`)→ 归一化为 `{}`
196
+ * (数组也算非对象:工具参数必须是对象);
197
+ * 4. 空白串 / 非法 JSON → `{ ok:false, deniedReason, auditReason, message }`(三段文案见上);
198
+ * 5. 不做前后缀宽容(严格 `JSON.parse`):```` ```json\n{"a":1}\n``` ```` 与 `'{"a":1} trailing'`
199
+ * 都是解析失败;
200
+ * 6. 不做长度上限:20000 字符的合法参数照常解析(6000 只是失败文案里的分块建议);
201
+ * 7. 非字符串 `raw` 走隐式 `ToString`:`42` → `42` → `{}`;`{}` → `'[object Object]'` → 解析失败;
202
+ * 任何解析异常都归入第 4 条;
203
+ * 8. 纯函数:不渲染、不审计、不写消息、不执行工具。
204
+ *
205
+ * @param {any} raw `tc.function?.arguments`(协议上是字符串;非字符串按 `JSON.parse` 的隐式转换处理)
206
+ * @returns {{ ok: true, args: any } | { ok: false, deniedReason: string, auditReason: string, message: string }}
207
+ */
208
+ export function parseToolArgs(raw) {
209
+ let args;
210
+ try {
211
+ args = JSON.parse(raw || '{}');
212
+ } catch {
213
+ return { ok: false, deniedReason: TOOL_ARGS_PARSE_DENIED, auditReason: TOOL_ARGS_PARSE_AUDIT, message: TOOL_ARGS_PARSE_MESSAGE };
214
+ }
215
+ if (!args || typeof args !== 'object' || Array.isArray(args)) args = {};
216
+ return { ok: true, args };
217
+ }
package/src/web/app.js CHANGED
@@ -457,6 +457,8 @@ async function send(){
457
457
  // v0.6.13(问题 3):等 `task` 子代理时把**子代理自己的进度**摆出来——
458
458
  // 真机现场界面/日志只有"等待工具 task 已 620s",与"卡死 620s"完全同形。
459
459
  if(ev.subagent) bits.push('子 Agent'+(ev.subagent.label?'「'+ev.subagent.label+'」':'')+'已跑 '+(ev.subagent.modelRounds||0)+' 轮 / '+(ev.subagent.toolCalls||0)+' 次工具调用'+(ev.subagent.ms!=null?'(最后进展 '+Math.round(ev.subagent.ms/1000)+'s 前)':''));
460
+ // v0.6.15(§3.59):本地端点串行化必须在界面上看得见(真机内存压力来自父回合 + 子代理并发)
461
+ if(ev.localQueue) bits.push('本地端点已串行化(防内存压力):等待 '+(ev.localQueue.active||0)+' 个在途请求、并发上限 '+(ev.localQueue.limit||1));
460
462
  if(ev.stalled) bits.push('已判定无进展,正在中止');
461
463
  think.textContent='💭 正在工作 '+Math.floor(p/60)+' 分 '+Math.round(p%60)+' 秒 · 第 '+msg._steps+' 回合 · '+curPhase+' · '+bits.join(' · ')+'…';
462
464
  renderWorkStatus(); }
package/src/web/server.js CHANGED
@@ -25,7 +25,7 @@ import { ensureHome, loadConfig, saveConfig, mingdaoHome } from '../config.js';
25
25
  import { setStoredKey, removeStoredKey, getStoredKey, maskKey } from '../credentials.js';
26
26
  import { availableModels, fetchProviderModels, providerHasKey, probeEndpoint, adaptiveTimeouts, probeMemoUsable } from '../model-discovery.js';
27
27
  import { isLocalBaseUrl, resolveModelCaps } from '../model-caps.js';
28
- import { createProvider, resolveProviderConfig, helperProvider, resolveVisionSupport } from '../providers/index.js';
28
+ import { createProvider, resolveProviderConfig, helperProvider, resolveVisionSupport, setLocalQueueObserver } from '../providers/index.js';
29
29
  import { MODELS, modelPreset, PROVIDERS } from '../models.js';
30
30
  import { routeTask, routingConfig } from '../routing.js';
31
31
  import { buildUserContent } from './attachments.js';
@@ -85,6 +85,13 @@ const PERM_HINT = /** @type {Record<string, string>} */ ({
85
85
  auto: '不再逐次确认,工具直接执行',
86
86
  readonly: '仅只读工具直接放行,其余逐次询问是否本次放行',
87
87
  });
88
+ /**
89
+ * v0.6.17 §3.64:`resolveTurnPermission().source` → 账本 `run.start.permissionSource`。
90
+ * 两套名字必须分开:`webui` 是「本模块内部从哪来」的措辞(既有测试与 banner 都依赖它),
91
+ * 账本枚举是跨入口的公共契约,显式选择统一叫 `explicit`(CLI/调度同样适用)。
92
+ * 这里是唯一的翻译点——不要在别处再写一份映射。
93
+ */
94
+ const LEDGER_PERMISSION_SOURCE = /** @type {Record<string, string>} */ ({ webui: 'explicit', preset: 'preset', config: 'config' });
88
95
 
89
96
  /**
90
97
  * 解析「本回合的有效权限」——把"谁说了算"收成**单源纯函数**(v0.6.12,用户实测 bug)。
@@ -293,6 +300,13 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
293
300
  // v0.6.13(A):定义提前到 provider 创建之前——本地端点的**预检**在 getProviderFor 里就要落日志,
294
301
  // 而启动期 `await getProviderFor(cfg.model)` 早于原先的定义位置(TDZ:会直接 ReferenceError)。
295
302
  const srvlog = createLogWriter(path.join(mingdaoHome(), 'logs', 'web-server.log'));
303
+ // v0.6.15(§3.59):本地端点串行化的日志必须落进 web-server.log(真机取证靠它),
304
+ // 而不是只打到 stderr。事件由 providers/index.js 的本地端点闸门在**入队时**发出(含在途数/并发上限)。
305
+ setLocalQueueObserver((/** @type {any} */ ev) => {
306
+ try {
307
+ srvlog(`${String(ev?.text || '本地端点串行化')}${ev?.label ? `(模型 ${ev.label})` : ''}`);
308
+ } catch {}
309
+ });
296
310
 
297
311
  const providerCache = new Map();
298
312
  // v0.6.13(A 追加):本地端点的**能力/延迟预检**结果(每次建 provider / 每轮对话开头的提示都读它)。
@@ -605,6 +619,8 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
605
619
  // v0.6.13(问题 3):等 `task` 子代理时把子代理自己的进度也写进日志——
606
620
  // 真机现场只有"正在等待工具 task(已 620s)",看不出子代理是在干活还是卡死。
607
621
  subagent: tp.subagent ?? null,
622
+ // v0.6.15(§3.59):本地端点因内存压力串行化时,进度行必须写"已串行化"
623
+ localQueue: tp.localQueue ?? null,
608
624
  })
609
625
  );
610
626
  };
@@ -635,6 +651,8 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
635
651
  ? { label: tp.subagent.label ?? '', modelRounds: tp.subagent.modelRounds ?? 0, toolCalls: tp.subagent.toolCalls ?? 0, ms: tp.subagent.at ? Date.now() - tp.subagent.at : null }
636
652
  : null,
637
653
  stalled: Boolean(tp?.stalled),
654
+ // v0.6.15(§3.59):本地端点串行化状态——界面据此显示"已串行化(防内存压力)"
655
+ localQueue: tp?.localQueue ?? null,
638
656
  });
639
657
  } catch {}
640
658
  try {
@@ -792,7 +810,12 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
792
810
  }
793
811
  // v0.6.12:**无论有没有预设,本回合生效的权限都以 turnPerm 为准**——显式选择在"没勾预设"这一格
794
812
  // 同样必须生效(此前 createPermission 读的是 chatCfg.permission,即 config.json 的值)。
813
+ // v0.6.17 §3.64:来源也随 cfg 进账本(`run.start.permissionSource`)。用户那次"选自动却按只读跑"
814
+ // 之所以难自查,一部分原因正是账本只留下生效值、看不出这个值是显式选的还是预设/配置压的。
795
815
  chatCfg = { ...chatCfg, permission: turnPerm.permission };
816
+ // 来源单独赋值:上面这一行是 smoke §137 的结构守卫锚点(chatCfg 的 permission 必须单源来自
817
+ // resolveTurnPermission),不要为了少一行把它并进对象字面量。
818
+ chatCfg.permissionSource = LEDGER_PERMISSION_SOURCE[turnPerm.source] ?? null;
796
819
  // v0.6.12:**把"本会话的有效权限"说出来**。这次用户困惑的根源不是权限算错了,而是
797
820
  // 「我选了自动、它按只读跑、而且没有任何一处告诉我」——预设是会话级粘滞的(下拉一直选着),
798
821
  // 用户以为换个权限档就覆盖了它。新会话开始与档位变更各说一次,不刷屏。