dsh-jev-prune 0.1.0 → 0.1.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1 — 2026-10-06
4
+
5
+ - Accept tools from verified 0.1 and 0.2 release families, dynamically report runtime version and capabilities, and resolve preset-local compaction services for each Agent.
6
+ - Add actual CLI installation and Web preset activation verification, plus an advisory rolling @next CI job. Keep the locked old-host regression fixture.
7
+
8
+ - Fix #48: scope receipts to asynchronous compaction transactions, clear inherited ownership at host region entry, validate replay input and preserve claim-once/cancellation behavior.
9
+ - Fix #49: invalidate result judgments when the recent user-instruction revision changes, retain effect judgments, refresh before manual actions and discard old-goal responses arriving in flight.
10
+ - Expand simulated-host checks from 113 to 130. The receipt/goal regression subset was reverse-verified against 0.1.0: 14 of its 127 assertions failed.
11
+
3
12
  ## 0.1.0 — 2026-10-06
4
13
 
5
14
  - Concise English and Chinese project introductions; implementation/configuration history moved behind architecture links.
package/README.md CHANGED
@@ -7,7 +7,7 @@ Structured judgments from [TypeSafe Jev](https://typesafe.ai) drive DSH's two-la
7
7
 
8
8
  **English** · [简体中文](README_zh.md)
9
9
 
10
- ![license](https://img.shields.io/badge/license-MIT-blue) ![node](https://img.shields.io/badge/node%20%3E%3D22.19-339933) ![dsh](https://img.shields.io/badge/DSH-0.1.x--rc-orange) [![CI](https://github.com/yangyu666/dsh-jev-prune/actions/workflows/ci.yml/badge.svg)](https://github.com/yangyu666/dsh-jev-prune/actions/workflows/ci.yml) ![smoke checks](https://img.shields.io/badge/smoke%20checks-113%20passing-success) [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/yangyu666/dsh-jev-prune)
10
+ ![license](https://img.shields.io/badge/license-MIT-blue) ![node](https://img.shields.io/badge/node%20%3E%3D22.19-339933) ![dsh](https://img.shields.io/badge/DSH-0.1.x--rc-orange) [![CI](https://github.com/yangyu666/dsh-jev-prune/actions/workflows/ci.yml/badge.svg)](https://github.com/yangyu666/dsh-jev-prune/actions/workflows/ci.yml) ![smoke checks](https://img.shields.io/badge/smoke%20checks-130%20passing-success) [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/yangyu666/dsh-jev-prune)
11
11
 
12
12
  ## The problem it solves
13
13
 
@@ -71,12 +71,16 @@ Probabilities are consumed as **relative quantiles**, never as a fixed threshold
71
71
 
72
72
  ## Install and quick start
73
73
 
74
- Requires Node `^22.19.0 || >=24.0.0`; tested against **DSH 0.1.5-rc.2**. The profile needs base pruner/compaction and tokenMeter services. Set `TYPESAFE_API_KEY` in the environment before starting DSH.
74
+ **Version note:** use 0.1.1 for DSH 0.2.0-rc.2. It supports both root and preset-local services and passed actual CLI installation/activation; older 0.1.0 is rejected by this host. [Compatibility evidence and rolling verification](docs/compatibility.md).
75
+
76
+ Requires Node `^22.19.0 || >=24.0.0`. Regression baseline: **DSH 0.1.5-rc.2**; real CLI install/preset activation verified on **0.2.0-rc.2**. The profile needs pruner/compaction and tokenMeter services. Set `TYPESAFE_API_KEY` before starting DSH.
75
77
 
76
78
  Install a pinned version:
77
79
 
78
80
  ```bash
79
- dsh plugin --profile web add github:yangyu666/dsh-jev-prune#v0.1.0
81
+ dsh plugin --profile web add dsh-jev-prune@0.1.1
82
+ # GitHub alternative:
83
+ dsh plugin --profile web add github:yangyu666/dsh-jev-prune#v0.1.1
80
84
  ```
81
85
 
82
86
  Local development:
@@ -84,7 +88,7 @@ Local development:
84
88
  ```bash
85
89
  git clone https://github.com/yangyu666/dsh-jev-prune.git
86
90
  cd dsh-jev-prune
87
- git checkout v0.1.0
91
+ git checkout v0.1.1
88
92
  npm ci
89
93
  npm run check
90
94
  npm run smoke
@@ -98,16 +102,14 @@ Without pnpm, run `node scripts/wire_profile.mjs <DSH_HOME> <profile-name>`. Aft
98
102
  Start with a dry run in the profile's plugin entry:
99
103
 
100
104
  ```yaml
101
- - insert:
102
- - id: jev-prune
103
- name: dsh-jev-prune
104
- config:
105
- dryRun: true
106
- judgeOn: pressure
107
- softLimit: "55%"
108
- compactReceipts: true
109
- compactOn: pressure
110
- compactSoftLimit: "70%"
105
+ - id: jev-prune
106
+ config:
107
+ dryRun: true
108
+ judgeOn: pressure
109
+ softLimit: "55%"
110
+ compactReceipts: true
111
+ compactOn: pressure
112
+ compactSoftLimit: "70%"
111
113
  ```
112
114
 
113
115
  After inspecting `/jev`, set `dryRun: false` to apply reductions.
@@ -148,8 +150,8 @@ The recording runs the real plugin `apply()`, registered tools and replacement p
148
150
 
149
151
  - Live judging sends history, paths, snippets and command output to TypeSafe and adds inference cost and latency.
150
152
  - Automated CI covers helpers, simulated-host behavior and loading against a locked host dependency tree. It does not exercise a live DSH session end to end.
151
- - Pending receipts are claim-once, but current fencing does not identify an external concurrent summary's transaction. Avoid concurrent compaction; see [review findings](docs/review-2026-10-05.md).
152
- - Result judgments are cached by sequence, without task-goal invalidation. Small-population layer-1 fallback can trim at zero pressure ratio.
153
+ - On current source, receipts are scoped to an asynchronous transaction and validated against the selected replay messages; foreign, canceled or mismatched summaries fall back to the host. The published 0.1.0 still has the older session-scoped behavior. See [architecture](docs/ARCHITECTURE.md).
154
+ - Current source invalidates result judgments when the last three user instructions change, retaining effect judgments and discarding in-flight answers for older goals. Small-population layer-1 fallback can still trim at zero pressure ratio.
153
155
  - `jev_restore` currently finds region summary checkpoints, truncates each returned event to 4,000 UTF-16 units, and does not directly resolve layer-1 or partial-result replacement IDs. Original events remain in the log.
154
156
 
155
157
  ## Layout and development
package/README_zh.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  [English](README.md) · **简体中文**
9
9
 
10
- ![license](https://img.shields.io/badge/license-MIT-blue) ![node](https://img.shields.io/badge/node%20%3E%3D22.19-339933) ![dsh](https://img.shields.io/badge/DSH-0.1.x--rc-orange) [![CI](https://github.com/yangyu666/dsh-jev-prune/actions/workflows/ci.yml/badge.svg)](https://github.com/yangyu666/dsh-jev-prune/actions/workflows/ci.yml) ![smoke checks](https://img.shields.io/badge/smoke%20checks-113%20passing-success) [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/yangyu666/dsh-jev-prune)
10
+ ![license](https://img.shields.io/badge/license-MIT-blue) ![node](https://img.shields.io/badge/node%20%3E%3D22.19-339933) ![dsh](https://img.shields.io/badge/DSH-0.1.x--rc-orange) [![CI](https://github.com/yangyu666/dsh-jev-prune/actions/workflows/ci.yml/badge.svg)](https://github.com/yangyu666/dsh-jev-prune/actions/workflows/ci.yml) ![smoke checks](https://img.shields.io/badge/smoke%20checks-130%20passing-success) [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/yangyu666/dsh-jev-prune)
11
11
 
12
12
  ## 它解决什么问题
13
13
 
@@ -69,12 +69,16 @@ DSH 自带的上下文回收是**纯体积**的:工具结果超过阈值就掐
69
69
 
70
70
  ## 安装与快速开始
71
71
 
72
+ **版本说明:** DSH 0.2.0-rc.2 请使用 0.1.1,它支持根级和 preset 内服务,已通过真实 CLI 安装与激活;旧的 0.1.0 会被该宿主拒绝。[兼容性证据与滚动验证](docs/compatibility.md)。
73
+
72
74
  要求 Node `^22.19.0 || >=24.0.0`;针对 **DSH 0.1.5-rc.2** 验证。profile 需要基础裁剪、压缩与 tokenMeter 服务。启动 DSH 前在环境中设置 `TYPESAFE_API_KEY`。
73
75
 
74
76
  固定版本安装:
75
77
 
76
78
  ```bash
77
- dsh plugin --profile web add github:yangyu666/dsh-jev-prune#v0.1.0
79
+ dsh plugin --profile web add dsh-jev-prune@0.1.1
80
+ # GitHub 安装方式:
81
+ dsh plugin --profile web add github:yangyu666/dsh-jev-prune#v0.1.1
78
82
  ```
79
83
 
80
84
  本地开发:
@@ -82,7 +86,7 @@ dsh plugin --profile web add github:yangyu666/dsh-jev-prune#v0.1.0
82
86
  ```bash
83
87
  git clone https://github.com/yangyu666/dsh-jev-prune.git
84
88
  cd dsh-jev-prune
85
- git checkout v0.1.0
89
+ git checkout v0.1.1
86
90
  npm ci
87
91
  npm run check
88
92
  npm run smoke
@@ -96,16 +100,14 @@ dsh plugin --profile web add link:/absolute/path/to/dsh-jev-prune
96
100
  先在 profile 的插件条目中开启 dry-run:
97
101
 
98
102
  ```yaml
99
- - insert:
100
- - id: jev-prune
101
- name: dsh-jev-prune
102
- config:
103
- dryRun: true
104
- judgeOn: pressure
105
- softLimit: "55%"
106
- compactReceipts: true
107
- compactOn: pressure
108
- compactSoftLimit: "70%"
103
+ - id: jev-prune
104
+ config:
105
+ dryRun: true
106
+ judgeOn: pressure
107
+ softLimit: "55%"
108
+ compactReceipts: true
109
+ compactOn: pressure
110
+ compactSoftLimit: "70%"
109
111
  ```
110
112
 
111
113
  检查 `/jev` 的判定和排除原因后,设为 `dryRun: false` 执行压缩。
@@ -146,8 +148,8 @@ dsh plugin --profile web add link:/absolute/path/to/dsh-jev-prune
146
148
 
147
149
  - 在线判定会将历史、路径、代码片段和命令输出发送至 TypeSafe,并增加判定成本与延迟。
148
150
  - CI 验证纯函数、模拟宿主行为和锁定宿主依赖树的模块加载;尚不覆盖真实 DSH 会话端到端执行。
149
- - 回执只能领取一次,但当前令牌无法识别外部并发摘要的事务归属;应避免并发压缩。[审查记录](docs/review-2026-10-05.md)
150
- - 结果判定按序号缓存,任务目标变化不会使其失效;第一层小样本降级可能在压力比例为零时裁剪。
151
+ - 当前源码将回执绑定到异步事务,并核对所选区间的实际消息;外部、取消或输入不匹配的摘要回退宿主。已发布的 0.1.0 仍使用旧机制。[架构说明](docs/ARCHITECTURE.md)
152
+ - 当前源码在最近三条用户指令变化时使结果判断失效,保留副作用判断,并丢弃旧目标的在途响应。第一层小样本降级仍可能在压力比例为零时裁剪。
151
153
  - `jev_restore` 只查区域摘要检查点,每个事件最多返回 4,000 个 UTF-16 单元,尚不能直接解析第一层或逐结果替换的 ID。原始事件仍保存在日志中。
152
154
 
153
155
  ## 目录与开发
@@ -20,6 +20,8 @@ flowchart LR
20
20
 
21
21
  ## Modules
22
22
 
23
+ - `compatibility.js` separates actual runtime version, verified-version evidence and service capabilities. The manifest owns supported release families.
24
+
23
25
  - `jev.js` owns the remote judge protocol, retry classification, batching, and local token estimation.
24
26
  - `prune.js` contains layer-1 pure logic: cached-verdict lookup, pressure-budget planning, code-point-safe slicing, and DSH-compatible replacement events.
25
27
  - `state.js` converts session events into the bounded state and questions sent to Jev. It also discovers tool names and selects candidates.
@@ -30,16 +32,20 @@ The dependency direction stays toward the pure modules. Host APIs must remain in
30
32
 
31
33
  ## Runtime flow
32
34
 
35
+ New Web profiles isolate compaction services in presets. The root plugin only injects tools, then resolves the active Agent's pruner, compaction and meter through the host `serviceForAgent` API before the base pruning hook. Per-engine hook maps prevent cross-preset sharing; legacy root services remain supported. [Compatibility verification](compatibility.md).
36
+
33
37
  1. A prepended `agent/pre-step` hook builds the current state and asks Jev only for missing verdict axes.
34
38
  2. DSH's `compaction-basic` hook calls the synchronously overridden `toolResultPruner.pruneSession`. Layer 1 reads the verdict cache and replaces stale result bodies with head/marker/tail content.
35
39
  3. The normal second plugin hook selects read-only call/result pairs whose result and effect verdicts are both low. Fully eligible steps become balanced contiguous ranges; mixed parallel batches become per-result replacements matched by `callId`.
36
- 4. Full ranges run through `compactRegion`. A one-use ownership token lets the temporary `summarize` override inject a deterministic receipt only for that transaction. Mixed batches use DSH's single-node shadow-price + `tool/result` replacement protocol, preserving every pair envelope and every rejected result.
40
+ 4. Full ranges run through the captured `compactRegion` in an AsyncLocalStorage transaction. The summary hook checks session, exact replay messages (with an optional system head), cancellation, lifetime and claim-once state. Host calls to the public region entry clear inherited receipt ownership. Mixed batches use DSH's single-node shadow-price + `tool/result` replacement protocol.
37
41
  5. Original events remain in the session log. The surface points to replacement or compaction events, and `jev_restore` can retrieve the shadowed text.
38
42
 
39
43
  ## State and identity
40
44
 
41
45
  Verdicts are held in a `WeakMap<session, Map<seq, verdict>>`. A layer-1 replacement receives a new seq and records the old seq in `sourceEventSeqs`; cache lookup follows that metadata so both layers keep the same judgment. In-memory verdicts are rebuilt after a process restart.
42
46
 
47
+ The result-axis cache is additionally keyed by a SHA-256 revision of the sequence identities and complete text of the latest three non-checkpoint user instructions. This is deliberately not the truncated 500-character judge header. A revision change clears result probabilities and sets a conservative keep, while retaining effect probabilities. Judge, synchronous pruner and manual compaction entry all refresh the revision; old-goal in-flight responses are discarded. Assistant/tool progress alone does not invalidate the cache. This policy detects instruction changes, not every semantic change in an evolving task.
48
+
43
49
  The pressure ratio is also session-scoped. `judgePass` computes it asynchronously, and the synchronous layer-1 override consumes it later in the same pre-step chain.
44
50
 
45
51
  ## Safety invariants
@@ -60,4 +66,4 @@ One assistant message may contain several parallel tool calls. They share one he
60
66
 
61
67
  Detailed configuration, gating, protocol history and troubleshooting moved to [the implementation reference](implementation.md). Measurement notes are in [measurements](measurements.md). The recorded [demo](../demo/README.md) runs real plugin code with a simulated host.
62
68
 
63
- The current fencing token supports claim-once behavior, but does not prove which external compaction owns a summary call. See [the reviewed limitations](review-2026-10-05.md) before using concurrent compaction.
69
+ The published 0.1.0 used a global active fence and session-keyed pending receipt. Current unreleased source replaces it with async transaction ownership and replay-input validation. Host serialization and surface-stability checks remain the host's responsibility. Alternate backends whose replay input differs from DSH's contract fall back to their original summarizer.
package/docs/PORTING.md CHANGED
@@ -4,6 +4,8 @@ This document describes the host surface required to run `dsh-jev-prune` outside
4
4
 
5
5
  ## Services
6
6
 
7
+ Current source detects the host dynamically and resolves preset-local services through the optional host `serviceForAgent` API. Required root injection is limited to tools; pruner/compaction services are retried for the actual Agent. The old locked fixture is a regression baseline. See [compatibility evidence](compatibility.md).
8
+
7
9
  The adapter in `index.js` consumes these services:
8
10
 
9
11
  | Service | Required behaviour |
@@ -0,0 +1,43 @@
1
+ # Host compatibility and direct installation
2
+
3
+ ## Version evidence (2026-10-06)
4
+
5
+ [Recorded verification evidence](current-host-verification.json)
6
+
7
+ | Plugin | DSH | Evidence |
8
+ |---|---|---|
9
+ | Published npm 0.1.0 | 0.1.5-rc.2 | Locked dependencies and simulated-host checks. |
10
+ | Published npm 0.1.0 | 0.2.0-rc.2 | Actual CLI rejects the old tools peer range; nothing installed. |
11
+ | Prepared 0.1.1 source | 0.2.0-rc.2 | CLI archive install, composed config, 130 addon checks, real Web startup and standard-preset service takeover passed. |
12
+
13
+ The release preparation verifies a generated 0.1.1 archive. Registry publication is checked separately, followed by installation of the exact published npm version; archive verification alone is not that registry check.
14
+
15
+ ## Locking versus compatibility
16
+
17
+ The old 0.1.5-rc.3 publication omitted required child packages; caret ranges drifted into that incomplete tree. The committed ci/dsh-host lockfile keeps old regressions reproducible. It is a test fixture, not a runtime pin. Published 0.1.0 separately declares an old tools peer range that newer DSH rejects before installation.
18
+
19
+ Startup reads the actual installed DSH version; the manifest supplies tested versions and supported families. Boot makes no registry requests and does not auto-update the host. Exact tested, untested-but-supported, unsupported and unknown versions are reported separately from service capabilities.
20
+
21
+ Known-family diagnostics do not bypass declared peer ranges: a future prerelease may still be rejected by DSH's semver check until its tuple is explicitly accepted and verified.
22
+
23
+ Legacy root services remain supported. New Web profiles isolate compaction inside Agent presets; the plugin calls the host's serviceForAgent API before the host pruning pass. It hooks each engine once and disposes hooks with the plugin. Presets without these services remain untouched. Unsupported shapes retain host fallback behavior.
24
+
25
+ The tools peer range is `^0.1.5-rc.2 || ^0.2.0-rc.2`. It intentionally does not accept every future release. Successful verification should precede widening it further.
26
+
27
+ ## Reproduce
28
+
29
+ ```bash
30
+ npm ci
31
+ npm run verify:current-host
32
+ DSH_VERIFY_SPEC=@deepseek-ai/dsh@0.2.0-rc.2 npm run verify:current-host
33
+ ```
34
+
35
+ PowerShell uses `$env:DSH_VERIFY_SPEC = '@deepseek-ai/dsh@0.2.0-rc.2'` before the command.
36
+
37
+ The script creates an isolated host/home, packs the plugin, runs the actual `dsh plugin --profile web add <archive> --ignore-scripts`, checks configuration and addon tests, starts Web on a temporary loopback port with `--no-open`, creates an empty standard-preset Agent and dispatches an empty pre-step. It verifies takeover flags, disposes the Agent and stops the child server. It sends no task and invokes no model inference; TypeSafe is disabled. Artifacts remain at the printed temporary path. An existing isolated installation can be reused with `-- --host-dir <path>`; each run still gets a fresh home.
38
+
39
+ Native build scripts are skipped. This proves this plugin's installation and attachment, not every native DSH tool. pnpm must be on PATH; initial downloads can take minutes.
40
+
41
+ ## CI boundaries
42
+
43
+ Locked old-version jobs remain gates. A rolling @next install/activation check runs on PRs, main and weekly, as an advisory job. A broken upstream publication is visible without replacing the baseline. This is installation/activation evidence, not live Jev quality or long-task success; measurement issue #37 remains separate.
@@ -0,0 +1,24 @@
1
+ {
2
+ "verifiedOn": "2026-10-06",
3
+ "hostVersion": "0.2.0-rc.2",
4
+ "pluginSourceVersion": "0.1.1",
5
+ "platform": "Windows",
6
+ "nodeVersion": "26.2.0",
7
+ "published010Installation": "rejected by DSH peer compatibility check; nothing installed",
8
+ "preparedArchiveInstallation": "passed through actual dsh plugin CLI",
9
+ "githubBranchInstallation": "passed through actual CLI with --ignore-scripts and no version exemption",
10
+ "configurationComposition": "passed",
11
+ "addonSmokeChecks": 130,
12
+ "realStandardPresetActivation": {
13
+ "success": true,
14
+ "pruner": "function",
15
+ "compaction": "function",
16
+ "takeoverInstalled": true,
17
+ "summaryHookInstalled": true,
18
+ "capabilities": {"tools": true, "layer1": true, "layer2": true, "pressure": true, "modelInfo": true}
19
+ },
20
+ "modelInference": "not invoked",
21
+ "nativeBuildScripts": "skipped",
22
+ "reproduce": "npm run verify:current-host",
23
+ "scope": "Installation and real service attachment, not live judge quality or long-task compaction success"
24
+ }
@@ -62,9 +62,9 @@ Probabilities are consumed as **relative quantiles**, never as a fixed threshold
62
62
 
63
63
  ## Receipt ownership (fence)
64
64
 
65
- The plugin assigns a token to each pending receipt, permits only one consumption, and clears only its own pending entry. This guards against duplicate consumption, but does not establish ownership of an external concurrent summary: external host paths do not update the plugin's active token. The original smoke C3 test checks a competing call after consumption, not competing arrival before consumption.
65
+ In published 0.1.0, the global fence did not establish transaction ownership. Current unreleased source scopes each receipt through AsyncLocalStorage, clears inherited ownership at the public host region entry, and verifies replay messages and session before injection. A legitimate single leading system message is allowed. A claimed, closed, expired or canceled entry cannot be reused.
66
66
 
67
- Until transaction identity is propagated or all entry paths are serialized, do not claim concurrency safety. [Reproduction and suggested remedy](review-2026-10-05.md#p1--external-summary-can-consume-another-transactions-receipt)
67
+ Tests cover both arrival orders, matching-input external summaries, concurrent sessions, nested host regions, system prefixes and cancellation. Different backend input shapes fall back to the host summarizer; host locks and surface mutation checks remain necessary. [Original 0.1.0 reproduction](review-2026-10-05.md#p1--external-summary-can-consume-another-transactions-receipt)
68
68
 
69
69
  ## Requirements
70
70
 
package/index.js CHANGED
@@ -27,6 +27,9 @@
27
27
  * @module dsh-jev-prune
28
28
  */
29
29
 
30
+ import { AsyncLocalStorage } from 'node:async_hooks'
31
+ import { createHash } from 'node:crypto'
32
+ import { isDeepStrictEqual } from 'node:util'
30
33
  import { readFileSync, writeFileSync } from 'node:fs'
31
34
  import { createRequire } from 'node:module'
32
35
  import { dirname, join } from 'node:path'
@@ -36,6 +39,7 @@ import z from '@deepseek-ai/schemastery'
36
39
  import { defineTool } from '@deepseek-ai/dsh-tools'
37
40
 
38
41
  import { JevClient, estimateTokens } from './src/jev.js'
42
+ import { classifyHostVersion, inspectHostCapabilities } from './src/compatibility.js'
39
43
  import { JEV_PRUNE_MARKER, isToolIn, parseLimit, pruneSessionWithJev } from './src/prune.js'
40
44
  import {
41
45
  DEFAULT_COMPACT_TOOLS,
@@ -79,8 +83,8 @@ import {
79
83
  * · 记进心跳与状态(可观测)
80
84
  * · major.minor 与测试版本不一致时打一次 warning(不拒绝加载——也许只是字段没变)
81
85
  */
82
- const TESTED_DSH_VERSION = '0.1.5-rc.2'
83
- const TESTED_DSH_SERIES = '0.1'
86
+ const HOST_POLICY = JSON.parse(readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8')).dsh.compatibility
87
+ const TESTED_DSH_VERSION = HOST_POLICY.testedVersions.join(', ')
84
88
 
85
89
  function detectDshVersion() {
86
90
  const readVersionAt = (path) => {
@@ -106,9 +110,8 @@ function detectDshVersion() {
106
110
  }
107
111
 
108
112
  const dshVersion = detectDshVersion()
109
- const dshVersionMatches = dshVersion === 'unknown'
110
- ? null // 探测不到 ≠ 不匹配,不吓唬人,只如实上报
111
- : dshVersion.split('.').slice(0, 2).join('.') === TESTED_DSH_SERIES
113
+ const detectedCompatibility = classifyHostVersion(dshVersion, HOST_POLICY)
114
+ const dshVersionMatches = detectedCompatibility.status === 'unknown' ? null : detectedCompatibility.status === 'tested'
112
115
 
113
116
  /**
114
117
  * `freezeMessage` 来自 @deepseek-ai/dsh-llm。用**动态导入**而不是静态导入:
@@ -137,15 +140,11 @@ export const name = 'jev-prune'
137
140
  /**
138
141
  * Cordis 的依赖声明。**这一步是整个插件能否生效的关键。**
139
142
  *
140
- * 实测踩过:只写 `['tools']` 时,Cordis 等到 tools 就绪就调用 apply(),
141
- * 而那会 `ctx.get('toolResultPruner')` 返回 null —— 服务还没注册
142
- * (pruner 由 base bundle 的 tool-result-pruner 条目提供)。结果插件加载成功、
143
- * 配置正确、installPrunerOverride 也跑了,但**静默地没接管任何东西**。
144
- * 这类失败在宿主日志里完全看不见,只能靠心跳文件/落盘才能发现。
145
- *
146
- * 把 pruner 也声明进来,Cordis 才会等它就绪再调 apply()。
143
+ * Only tools is a required root service. New Web profiles place pruner and
144
+ * compaction in isolated preset realms, so requiring them here stalls startup.
145
+ * Their actual instances are resolved and hooked before each Agent step.
147
146
  */
148
- export const inject = ['tools', 'toolResultPruner']
147
+ export const inject = ['tools']
149
148
 
150
149
  /** 裁剪标记与裁剪机制从 prune.js 复用(那边才能被单测覆盖)。 */
151
150
  export { JEV_PRUNE_MARKER }
@@ -575,8 +574,26 @@ export function apply(ctx, config, deps = {}) {
575
574
  const cfg = resolveConfig(config)
576
575
  if (!cfg.enabled) return
577
576
 
578
- if (dshVersionMatches === false) {
579
- ctx.logger?.info?.(`[jev-prune] DSH ${dshVersion} 与测试版本 ${TESTED_DSH_VERSION} 不同系列 —— 事件字段可能已漂移,建议先跑 jev_probe_shapes 核对`)
577
+ // Newer Web profiles isolate compaction services in preset realms. Resolve
578
+ // them through the host API for the actual agent, never fabricate a realm.
579
+ let serviceForAgent = deps.serviceForAgent ?? null
580
+ const scopedServicesReady = serviceForAgent != null ? Promise.resolve() :
581
+ import('@deepseek-ai/dsh-agent-preset-registry').then(module => {
582
+ serviceForAgent = typeof module.serviceForAgent === 'function' ? module.serviceForAgent : null
583
+ }).catch(() => {})
584
+ const hostService = (name, agent) => {
585
+ if (agent?.ctx && serviceForAgent != null) {
586
+ const scoped = serviceForAgent(ctx, agent, name)
587
+ if (scoped != null) return scoped
588
+ }
589
+ try { return ctx.get?.(name) ?? ctx[name] ?? null } catch { return null }
590
+ }
591
+ const prunerHooks = new WeakSet()
592
+ const summaryHooks = new WeakSet()
593
+ let agentCapabilities = null
594
+
595
+ if (['untested', 'unsupported'].includes(detectedCompatibility.status)) {
596
+ ctx.logger?.info?.(`[jev-prune] DSH ${dshVersion}: ${detectedCompatibility.status}(已验证 ${TESTED_DSH_VERSION});按服务能力接入,请先用 dryRun 和 jev_probe_shapes 核对`)
580
597
  }
581
598
 
582
599
  // 每个 apply 实例持有自己的解析结果,避免一次测试/一次宿主加载污染其他实例。
@@ -596,6 +613,23 @@ export function apply(ctx, config, deps = {}) {
596
613
 
597
614
  /** session → Map(结果 seq → {keep, prob, effectProb, chars, tool}) */
598
615
  const decisions = new WeakMap()
616
+ const goalVersions = new WeakMap()
617
+ function refreshGoal(session) {
618
+ const events = sessionEvents(session)
619
+ const goals = events.filter(event => event?.type === 'user/message'
620
+ && !isCheckpointEvent(event)
621
+ && (event.data?.source?.kind == null || event.data.source.kind === 'user')
622
+ && eventText(event).trim().length > 0).slice(-3)
623
+ const version = createHash('sha256').update(JSON.stringify(goals.map(event => [event.seq, eventText(event)]))).digest('hex')
624
+ const previous = goalVersions.get(session)
625
+ if (previous != null && previous !== version) {
626
+ const cache = decisions.get(session)
627
+ for (const [seq, verdict] of cache ?? []) cache.set(seq, { ...verdict, prob: null, keep: true })
628
+ pressureRatios.set(session, 0)
629
+ }
630
+ goalVersions.set(session, version)
631
+ return { version, goal: recentGoal(events) }
632
+ }
599
633
  /**
600
634
  * session → 压力缺口比例(0~1)。judgePass(异步,能拿到 used/window)算好存这里,
601
635
  * pruneSession(同步,DSH 调)读它来决定「这一轮裁多少」。读不到 = 0 = 不裁。
@@ -748,6 +782,7 @@ export function apply(ctx, config, deps = {}) {
748
782
  now: new Date().toISOString(),
749
783
  pid: typeof process !== 'undefined' ? process.pid : null,
750
784
  dshVersion: { version: dshVersion, testedAgainst: TESTED_DSH_VERSION, matchesTested: dshVersionMatches },
785
+ compatibility: { ...detectedCompatibility, capabilities: agentCapabilities ?? inspectHostCapabilities(ctx) },
751
786
  judgeReady: judge.ready !== false,
752
787
  model: cfg.model,
753
788
  // 越界配置被钳制的记录(issue #28)。空数组 = 配置全部合法。
@@ -825,6 +860,7 @@ export function apply(ctx, config, deps = {}) {
825
860
  stats.lastJudgeSkipReason = '没有活动会话'
826
861
  return
827
862
  }
863
+ const { version: goalVersion, goal } = refreshGoal(session)
828
864
  if (session.surface?.nodes == null || judge.ready === false) {
829
865
  // 早退也刷新压力比例(review 反馈):否则沿用上一轮的值,陈旧。
830
866
  pressureRatios.set(session, 0)
@@ -911,7 +947,7 @@ export function apply(ctx, config, deps = {}) {
911
947
  // 默认 0 = 不裁(失败方向:算不出压力就不动手)。
912
948
  let pressureRatio = 0
913
949
  if (cfg.judgeOn !== 'always') {
914
- const meter = ctx.get('tokenMeter')
950
+ const meter = hostService('tokenMeter', agent)
915
951
  let used = 0
916
952
  let measured = false
917
953
  try {
@@ -981,7 +1017,6 @@ export function apply(ctx, config, deps = {}) {
981
1017
  return
982
1018
  }
983
1019
 
984
- const goal = recentGoal(sessionEvents(session))
985
1020
  const { state, fitted, stateTokens } = buildJevState({
986
1021
  surface,
987
1022
  eventAt,
@@ -1043,6 +1078,13 @@ export function apply(ctx, config, deps = {}) {
1043
1078
  log('info', `判定批次失败(${batchFailures}/${batches.length}):${error?.message ?? String(error)}`)
1044
1079
  continue
1045
1080
  }
1081
+ if (refreshGoal(session).version !== goalVersion) {
1082
+ stats.requests += judge.requests - startRequests
1083
+ stats.skipped += fresh.length
1084
+ stats.lastNote = '任务目标在判定期间改变,丢弃旧目标的响应'
1085
+ pressureRatios.set(session, 0)
1086
+ return
1087
+ }
1046
1088
  succeeded += 1
1047
1089
  // 遍历本批里的**候选**(一个候选有两个题号 result_sN / effect_sN)。
1048
1090
  // 注意:两轴可能落在**不同批**(预算小的时候每题一批),所以本批只写
@@ -1132,6 +1174,7 @@ export function apply(ctx, config, deps = {}) {
1132
1174
  // 逐节点裁决逻辑放在 prune.js 里(纯函数 + 依赖注入),这样才能脱离 DSH 单测。
1133
1175
  // 这里只负责补齐它需要的依赖。
1134
1176
  function pruneViaJev(pruner, session) {
1177
+ refreshGoal(session)
1135
1178
  const nameByCallId = buildToolNameIndex(sessionEvents(session))
1136
1179
  const out = pruneSessionWithJev({
1137
1180
  pruner,
@@ -1159,10 +1202,10 @@ export function apply(ctx, config, deps = {}) {
1159
1202
  return out
1160
1203
  }
1161
1204
 
1162
- function installPrunerOverride() {
1163
- if (takeover.installed) return () => {}
1205
+ function installPrunerOverride(agent) {
1206
+ const pruner = hostService('toolResultPruner', agent)
1207
+ if (pruner != null && prunerHooks.has(pruner)) return () => {}
1164
1208
  takeover = { attempted: true, installed: false, reason: '' }
1165
- const pruner = ctx.get('toolResultPruner') ?? ctx.toolResultPruner
1166
1209
  if (pruner == null || typeof pruner.pruneSession !== 'function') {
1167
1210
  takeover.reason = pruner == null
1168
1211
  ? 'ctx.toolResultPruner 不存在 —— 需加载 @deepseek-ai/dsh-compaction-tool-result-pruner'
@@ -1173,15 +1216,18 @@ export function apply(ctx, config, deps = {}) {
1173
1216
  }
1174
1217
  const originalSession = pruner.pruneSession.bind(pruner)
1175
1218
  pruner.pruneSession = (session) => pruneViaJev(pruner, session)
1219
+ prunerHooks.add(pruner)
1176
1220
  takeover = { attempted: true, installed: true, reason: 'ok' }
1177
1221
  log('info', `已接管 ctx.toolResultPruner.pruneSession(keepThreshold=${cfg.keepThreshold}, preserveRecent=${cfg.preserveRecent}, dryRun=${cfg.dryRun})`)
1178
1222
  // 越界配置必须在加载时就喊出来(issue #28):钳制后的行为与用户写下的配置不一致,
1179
1223
  // 若不提示,用户会一直以为"我配了但没生效"是插件的 bug。
1180
1224
  for (const w of cfg[CONFIG_WARNINGS] ?? []) log('warn', w)
1181
1225
  writeHeartbeat()
1182
- return () => {
1226
+ ctx.effect(() => () => {
1183
1227
  pruner.pruneSession = originalSession
1184
- }
1228
+ prunerHooks.delete(pruner)
1229
+ })
1230
+ return () => {}
1185
1231
  }
1186
1232
 
1187
1233
  // ---------------------------------------------------------- 第二层:回执压缩
@@ -1197,33 +1243,20 @@ export function apply(ctx, config, deps = {}) {
1197
1243
  // 发起的压缩(DSH 自己的自动压缩、另一条并发路径)都会调到 summarize,把回执抢走——
1198
1244
  // 结果是"别人那段被换成了我们的确定性回执,而我们要压的那段反而用了模型摘要"。
1199
1245
  //
1200
- // 修法分两层,缺一不可:
1201
- // ① **归属令牌(fencing token)**:每次 compactRegion 前发一个新令牌,并把它记在
1202
- // `activeFence` 上。summarize 只在「当前 activeFence === 待用回执的令牌」时才注入——
1203
- // 即证明"这次 summarize 是在我们那次 compactRegion 的调用栈/时序内发生的"。
1204
- // await 期间若被别处的调用抢先,activeFence 会被对方改写,我们自然不注入。
1205
- // ② **一次性领取(claim-once)**:回执被消费后立刻 delete,且令牌是一次性的,
1206
- // 防止同一次压缩里 summarize 被调用多次时重复注入。
1207
- // ③ **归属校验(owner check)**:compactRegion 返回后核对令牌是否仍属于本次调用,
1208
- // 不属于则说明中途被打断,如实记进 action,不谎报成功。
1209
- let fenceCounter = 0
1210
- /**
1211
- * 当前"活跃"的压缩令牌。每次我们要调 compactRegion 时自增并置为最新值;
1212
- * summarize 只在待用回执的令牌与之相等时才注入。它是"归属证明":
1213
- * 我们那次 compactRegion 里面派发的 summarize 一定看到自己的令牌,
1214
- * 而 await 期间被别处抢先发起的压缩会把它改写成对方的令牌。
1215
- */
1216
- let activeFence = 0
1217
- const pendingReceipt = new WeakMap()
1218
-
1219
- function summaryService() {
1220
- return ctx.get?.('compaction') ?? ctx.compaction ?? null
1246
+ // AsyncLocalStorage supplies transaction identity across await boundaries.
1247
+ // Host region entry clears inherited ownership; exact input matching is an
1248
+ // additional guard for direct summarize calls inside the producer chain.
1249
+ const receiptContext = new AsyncLocalStorage()
1250
+ const runReceiptCompactions = new WeakMap()
1251
+
1252
+ function summaryService(agent) {
1253
+ return hostService('compaction', agent)
1221
1254
  }
1222
1255
 
1223
- function installSummaryHook() {
1224
- if (summaryHook.installed) return () => {}
1256
+ function installSummaryHook(agent) {
1257
+ const compaction = summaryService(agent)
1258
+ if (compaction != null && summaryHooks.has(compaction)) return () => {}
1225
1259
  summaryHook = { attempted: true, installed: false, reason: '' }
1226
- const compaction = summaryService()
1227
1260
  if (compaction == null || typeof compaction.summarize !== 'function') {
1228
1261
  summaryHook.reason = compaction == null
1229
1262
  ? 'ctx.compaction 不存在 —— 需加载 @deepseek-ai/dsh-compaction-basic'
@@ -1232,17 +1265,25 @@ export function apply(ctx, config, deps = {}) {
1232
1265
  writeHeartbeat()
1233
1266
  return () => {}
1234
1267
  }
1235
- const original = compaction.summarize.bind(compaction)
1236
- compaction.summarize = async (input, agent, signal) => {
1237
- const entry = pendingReceipt.get(agent?.session)
1238
- // 三道闸都要过才算"这是我们的那一次"(issue #29):
1239
- // · entry 存在、未过期
1240
- // · 未被领取(claimed)—— 同一次压缩里 summarize 若被多次调用,只注入一次
1241
- // · 令牌仍是当前活跃令牌 —— 证明这次 summarize 发生在我们那次 compactRegion 之内,
1242
- // 而不是被 await 期间别处的并发压缩抢先调用
1268
+ const original = compaction.summarize
1269
+ const originalRegion = compaction.compactRegion
1270
+ if (typeof originalRegion !== 'function') {
1271
+ summaryHook.reason = 'compaction.compactRegion 不是函数'
1272
+ return () => {}
1273
+ }
1274
+ const wrappedRegion = (...args) => receiptContext.run(undefined, () => originalRegion.apply(compaction, args))
1275
+ runReceiptCompactions.set(compaction, (entry, ...args) => receiptContext.run(entry, () => originalRegion.apply(compaction, args)))
1276
+ const wrappedSummarize = async (input, agent, signal) => {
1277
+ const entry = receiptContext.getStore()
1278
+ const messages = input?.messages
1279
+ const matches = entry != null && Array.isArray(messages)
1280
+ && (isDeepStrictEqual(messages, entry.messages)
1281
+ || (messages.length === entry.messages.length + 1 && messages[0]?.role === 'system'
1282
+ && isDeepStrictEqual(messages.slice(1), entry.messages)))
1283
+ // Require both async transaction ownership and matching replay input.
1243
1284
  if (entry != null
1244
1285
  && !entry.claimed
1245
- && entry.fence === activeFence
1286
+ && !entry.closed && entry.session === agent?.session && matches && !signal?.aborted
1246
1287
  && Date.now() - entry.at < 5 * 60 * 1000) {
1247
1288
  entry.claimed = true
1248
1289
  stats.receiptSummaries += 1
@@ -1252,24 +1293,27 @@ export function apply(ctx, config, deps = {}) {
1252
1293
  model: 'deterministic',
1253
1294
  }
1254
1295
  }
1255
- // 不是我们的:如实退回原实现,绝不吞掉别人的摘要。
1256
- // 若 entry 存在但令牌不匹配,说明恰好撞上竞态 —— 记一笔,让"回执被抢"可观测。
1257
- if (entry != null && !entry.claimed && entry.fence !== activeFence) {
1258
- stats.receiptFenceMisses += 1
1259
- }
1260
- return original(input, agent, signal)
1296
+ // Foreign, canceled or shape-mismatched calls use the original backend.
1297
+ return original.call(compaction, input, agent, signal)
1261
1298
  }
1299
+ compaction.summarize = wrappedSummarize
1300
+ compaction.compactRegion = wrappedRegion
1301
+ summaryHooks.add(compaction)
1262
1302
  summaryHook = { attempted: true, installed: true, reason: 'ok' }
1263
1303
  log('info', `已接管 ctx.compaction.summarize(回执模式;compactOn=${cfg.compactOn}, quantile=${cfg.compactQuantile})`)
1264
1304
  writeHeartbeat()
1265
- return () => {
1266
- compaction.summarize = original
1267
- }
1305
+ ctx.effect(() => () => {
1306
+ if (compaction.summarize === wrappedSummarize) compaction.summarize = original
1307
+ if (compaction.compactRegion === wrappedRegion) compaction.compactRegion = originalRegion
1308
+ summaryHooks.delete(compaction)
1309
+ runReceiptCompactions.delete(compaction)
1310
+ })
1311
+ return () => {}
1268
1312
  }
1269
1313
 
1270
1314
  /** 用宿主自己的 token meter 量一段 surface 跨度的 token 数。 */
1271
1315
  function spanTokens(agent, seqs) {
1272
- const meter = ctx.get?.('tokenMeter')
1316
+ const meter = hostService('tokenMeter', agent)
1273
1317
  if (meter == null || typeof meter.measure !== 'function') return null
1274
1318
  try {
1275
1319
  const wanted = new Set(seqs)
@@ -1327,12 +1371,17 @@ export function apply(ctx, config, deps = {}) {
1327
1371
  report.blocked = '没有活动会话'
1328
1372
  return report
1329
1373
  }
1330
- const compaction = summaryService()
1374
+ refreshGoal(session)
1375
+ await scopedServicesReady
1376
+ installPrunerOverride(agent)
1377
+ installSummaryHook(agent)
1378
+ agentCapabilities = inspectHostCapabilities({get: name => hostService(name, agent)})
1379
+ const compaction = summaryService(agent)
1331
1380
  if (compaction == null || typeof compaction.compactRegion !== 'function') {
1332
1381
  report.blocked = 'ctx.compaction 不可用(compactRegion 缺失)'
1333
1382
  return report
1334
1383
  }
1335
- if (!summaryHook.installed) {
1384
+ if (!summaryHooks.has(compaction)) {
1336
1385
  // 没有 summarize 接入点 = 我们的回执注不进去 = 会退化成模型摘要。宁可不做。
1337
1386
  report.blocked = `summarize 未接管(${summaryHook.reason || '未尝试'})—— 不做第二层,避免退化成模型摘要`
1338
1387
  return report
@@ -1345,7 +1394,7 @@ export function apply(ctx, config, deps = {}) {
1345
1394
  // 压力门:整对删除比截断风险大,所以默认阈值更高(70% vs 第一层的 55%)
1346
1395
  // 失败方向与第一层一致(issue #32):解析不出阈值、或拿不到用量,都**不做**。
1347
1396
  if (!force && cfg.compactOn !== 'always') {
1348
- const meter = ctx.get?.('tokenMeter')
1397
+ const meter = hostService('tokenMeter', agent)
1349
1398
  let used = 0
1350
1399
  let measured = false
1351
1400
  try {
@@ -1492,7 +1541,7 @@ export function apply(ctx, config, deps = {}) {
1492
1541
  let applied = 0
1493
1542
  let appliedChars = 0
1494
1543
  try {
1495
- const meter = ctx.get?.('tokenMeter')
1544
+ const meter = hostService('tokenMeter', agent)
1496
1545
  for (let offset = 0; offset < pairs.length; offset += 1) {
1497
1546
  const pair = pairs[offset]
1498
1547
  const event = session.eventAt(pair.seq)
@@ -1559,17 +1608,15 @@ export function apply(ctx, config, deps = {}) {
1559
1608
  continue
1560
1609
  }
1561
1610
 
1562
- // 发一个一次性令牌并抢占 activeFence(issue #29)。
1563
- // 之后若别处的并发压缩改写了 activeFence,我们这次的 summarize 就不会注入回执,
1564
- // 也就不会把别人的区间替换成我们的回执——那是原实现最危险的失败模式。
1565
- const fence = (fenceCounter += 1)
1566
- activeFence = fence
1567
- pendingReceipt.set(session, { text: receipt, at: Date.now(), fence, claimed: false })
1611
+ // The receipt is scoped to this transaction, including asynchronous work.
1612
+ const entry = {
1613
+ session, text: receipt, at: Date.now(), claimed: false, closed: false,
1614
+ messages: structuredClone(spanSeqs.map(seq => session.deriveEventMessage(session.eventAt(seq))).filter(message => message != null)),
1615
+ }
1568
1616
  try {
1569
- const result = await compaction.compactRegion(range.start, range.end, agent, options.signal)
1570
- // 归属校验:compactRegion 返回时令牌若已被别人改写,说明这次压缩中途被打断
1571
- // (或我们的回执被别人消费了)。此时不能谎报成功——如实记下来。
1572
- if (activeFence !== fence) {
1617
+ const result = await runReceiptCompactions.get(compaction)(entry, range.start, range.end, agent, options.signal)
1618
+ action.receiptInjected = entry.claimed
1619
+ if (!entry.claimed) {
1573
1620
  stats.receiptFenceMisses += 1
1574
1621
  action.fenceLost = true
1575
1622
  }
@@ -1592,11 +1639,8 @@ export function apply(ctx, config, deps = {}) {
1592
1639
  action.error = error?.message ?? String(error)
1593
1640
  report.actions.push(action)
1594
1641
  } finally {
1595
- // 只清理**自己**的令牌:若期间已被别人改写,那个令牌归对方管,不要去动它
1596
- // (原实现无论谁覆盖都无条件 delete(session),会把别人的待用回执一起清掉)
1597
- const current = pendingReceipt.get(session)
1598
- if (current?.fence === fence) pendingReceipt.delete(session)
1599
- if (activeFence === fence) activeFence = 0
1642
+ // Descendant tasks cannot claim a receipt after the producer finished.
1643
+ entry.closed = true
1600
1644
  }
1601
1645
  }
1602
1646
 
@@ -1632,6 +1676,7 @@ export function apply(ctx, config, deps = {}) {
1632
1676
  start: a.start, end: a.end, ok: a.ok ?? null, dryRun: a.dryRun ?? null,
1633
1677
  calls: a.calls, resultChars: a.resultChars, receiptTokens: a.receiptTokens,
1634
1678
  shadowedTokens: a.shadowedTokens, partial: a.partial ?? false,
1679
+ receiptInjected: a.receiptInjected ?? null, fenceLost: a.fenceLost ?? false,
1635
1680
  partialResults: a.partialResults ?? null, incomplete: a.incomplete ?? false,
1636
1681
  error: a.error ?? null, skipped: a.skipped ?? null,
1637
1682
  })),
@@ -1652,8 +1697,10 @@ export function apply(ctx, config, deps = {}) {
1652
1697
  stats.preStepEvents = (stats.preStepEvents ?? 0) + 1
1653
1698
  // 双保险:如果 apply() 时服务还没就绪,每次 pre-step 再试一次。
1654
1699
  // 依赖时序这种东西不该让插件"看起来加载成功、实际什么都没做"。
1655
- if (!takeover.installed) installPrunerOverride()
1656
- if (!summaryHook.installed) installSummaryHook()
1700
+ await scopedServicesReady
1701
+ installPrunerOverride(agent)
1702
+ installSummaryHook(agent)
1703
+ agentCapabilities = inspectHostCapabilities({get: name => hostService(name, agent)})
1657
1704
  if (judge.ready === false) {
1658
1705
  stats.errors += 1
1659
1706
  stats.lastNote = '未配置 TYPESAFE_API_KEY,跳过判定'
@@ -1707,7 +1754,7 @@ export function apply(ctx, config, deps = {}) {
1707
1754
  function renderStatus(agent) {
1708
1755
  const session = agent?.session
1709
1756
  const cache = session != null ? (decisions.get(session) ?? new Map()) : new Map()
1710
- const meter = ctx.get('tokenMeter')
1757
+ const meter = hostService('tokenMeter', agent)
1711
1758
  const used = session != null && typeof meter?.measure === 'function' ? (meter.measure(session)?.totalTokens ?? 0) : 0
1712
1759
  // 工具名索引诊断:白名单不命中时,这一行能立刻告诉你"是名字没配上"
1713
1760
  const nameProbe = session?.surface?.nodes != null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-jev-prune",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Jev-judged context compaction for DeepSeek Harness. Two layers: prune stale tool results (semantic instead of size-only), and replace spent tool-call ranges with a deterministic receipt instead of a model-written summary.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -32,11 +32,12 @@
32
32
  "check": "node test/check.js",
33
33
  "smoke": "node test/smoke_apply.mjs",
34
34
  "demo": "node demo/run.mjs",
35
- "coverage": "node --experimental-test-coverage --test --test-coverage-include=src/jev.js --test-coverage-include=src/prune.js --test-coverage-include=src/receipt.js --test-coverage-include=src/state.js --test-coverage-lines=90 --test-coverage-branches=75 --test-coverage-functions=75 test/check.js test/smoke_apply.mjs"
35
+ "verify:current-host": "node scripts/verify_current_host.mjs",
36
+ "coverage": "node --experimental-test-coverage --test --test-coverage-include=src/compatibility.js --test-coverage-include=src/jev.js --test-coverage-include=src/prune.js --test-coverage-include=src/receipt.js --test-coverage-include=src/state.js --test-coverage-lines=90 --test-coverage-branches=75 --test-coverage-functions=75 test/check.js test/smoke_apply.mjs"
36
37
  },
37
38
  "peerDependencies": {
38
- "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
39
- "@deepseek-ai/schemastery": "*"
39
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.2 || ^0.2.0-rc.2",
40
+ "@deepseek-ai/schemastery": "^3.18.2"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@deepseek-ai/dsh-llm": "0.1.5-rc.2",
@@ -71,7 +72,9 @@
71
72
  },
72
73
  "compatibility": {
73
74
  "testedAgainst": "@deepseek-ai/dsh@0.1.5-rc.2",
74
- "note": "DSH 0.1.x is a pre-release line; plugin-facing surfaces (event shapes, service names) may change between rc versions. After upgrading DSH, run `node test/check.js` and the smoke test, and use the `jev_probe_shapes` tool once in a real session."
75
+ "testedVersions": ["0.1.5-rc.2", "0.2.0-rc.2"],
76
+ "supportedSeries": ["0.1", "0.2"],
77
+ "note": "Version detection is dynamic; testedVersions records evidence, not a runtime pin. Unknown versions are reported and services are inspected. Latest-host install/activation is checked separately from the locked 0.1.5-rc.2 fixture."
75
78
  }
76
79
  }
77
80
  }
@@ -0,0 +1,75 @@
1
+ /** Disposable CLI install + real preset activation, without model requests. */
2
+ import assert from 'node:assert/strict'
3
+ import {existsSync,mkdtempSync,readFileSync,writeFileSync} from 'node:fs'
4
+ import {tmpdir} from 'node:os'
5
+ import {dirname,join,resolve} from 'node:path'
6
+ import {fileURLToPath,pathToFileURL} from 'node:url'
7
+ import {spawn,spawnSync} from 'node:child_process'
8
+ import {createServer} from 'node:net'
9
+
10
+ const repo=resolve(dirname(fileURLToPath(import.meta.url)),'..')
11
+ const npmCli=process.env.npm_execpath
12
+ assert.ok(npmCli?.endsWith('.js'),'Run through npm run verify:current-host')
13
+ const arg=process.argv.indexOf('--host-dir')
14
+ const host=arg<0?mkdtempSync(join(tmpdir(),'dsh-host-verification-')):resolve(process.argv[arg+1])
15
+ const run=(program,args,cwd=repo,env=process.env)=>{
16
+ const result=spawnSync(program,args,{cwd,env,encoding:'utf8',timeout:600000,maxBuffer:4*1024*1024,windowsHide:true})
17
+ assert.equal(result.status,0,(result.stderr||result.stdout||String(result.error)).replace(/([?&]token=)[^\s]+/g,'$1<redacted>'))
18
+ return result.stdout
19
+ }
20
+ if(arg<0){
21
+ const spec=process.env.DSH_VERIFY_SPEC||'@deepseek-ai/dsh@next'
22
+ console.log('Installing isolated host:',spec)
23
+ run(process.execPath,[npmCli,'install','--prefix',host,'--ignore-scripts','--no-audit','--no-fund',spec])
24
+ }
25
+ const bin=join(host,'node_modules/@deepseek-ai/dsh/lib/bin.js')
26
+ assert.ok(existsSync(bin))
27
+ const version=JSON.parse(readFileSync(join(host,'node_modules/@deepseek-ai/dsh/package.json'),'utf8')).version
28
+ const home=mkdtempSync(join(host,'verification-home-'))
29
+ const env={...process.env,DSH_HOME:home,TYPESAFE_API_KEY:''}
30
+ const archive=JSON.parse(run(process.execPath,[npmCli,'pack','--json','--pack-destination',home]))[0].filename
31
+ const packed=join(home,archive)
32
+ run(process.execPath,[bin,'plugin','--profile','web','add',packed,'--ignore-scripts'],repo,env)
33
+ const dump=run(process.execPath,[bin,'--profile','web','--dump-config'],repo,env)
34
+ assert.match(dump,/name: dsh-jev-prune/)
35
+ const addon=join(home,'profiles/web/node_modules/dsh-jev-prune')
36
+ run(process.execPath,[join(addon,'test/check.js')],host,env)
37
+ run(process.execPath,[join(addon,'test/smoke_apply.mjs')],host,env)
38
+ const heartbeat=join(home,'heartbeat.json'),resultPath=join(home,'activation.json')
39
+ const probe=join(home,'probe.mjs')
40
+ writeFileSync(probe,`import {readFileSync,writeFileSync} from 'node:fs';
41
+ import {randomUUID} from 'node:crypto';
42
+ import {agentEvents} from '@deepseek-ai/dsh-agent';
43
+ import {serviceForAgent} from '@deepseek-ai/dsh-agent-preset-registry';
44
+ export const inject=['agents','agentPresets','agentLoop'];
45
+ export function apply(ctx){ctx.effect(()=>{const timer=setTimeout(async()=>{let handle;try{
46
+ handle=await ctx.agents.create({sessionId:randomUUID(),meta:{cwd:${JSON.stringify(host)},agentPreset:'standard'},setup:async scope=>{await ctx.agentPresets.mount(scope,'standard')}});
47
+ const agent=handle.agent;
48
+ await agentEvents(ctx,agent).waterfall('agent/pre-step',{signal:new AbortController().signal,messages:[],turn:0,step:0},async()=>({kind:'enter',messages:[]}));
49
+ const state=JSON.parse(readFileSync(${JSON.stringify(heartbeat)},'utf8'));
50
+ writeFileSync(${JSON.stringify(resultPath)},JSON.stringify({success:true,pruner:typeof serviceForAgent(ctx,agent,'toolResultPruner')?.pruneSession,compaction:typeof serviceForAgent(ctx,agent,'compaction')?.compactRegion,takeover:state.takeover,summaryHook:state.summaryHook,compatibility:state.compatibility}));
51
+ }catch(error){writeFileSync(${JSON.stringify(resultPath)},JSON.stringify({success:false,error:String(error)}));}finally{await handle?.dispose();}},2000);return()=>clearTimeout(timer);});}
52
+ `)
53
+ // This profile was created solely by this verification run.
54
+ writeFileSync(join(home,'profiles/web/cordis.patch.yml'),
55
+ '- id: jev-prune\n config:\n dryRun: true\n heartbeatFile: '+JSON.stringify(heartbeat)+'\n- insert:\n - id: activation-probe\n name: '+JSON.stringify(pathToFileURL(probe).href)+'\n')
56
+ const server=createServer()
57
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
58
+ const port=server.address().port
59
+ await new Promise(resolve=>server.close(resolve))
60
+ let diagnostic=''
61
+ const child=spawn(process.execPath,[bin,'--profile','web','--no-open','--port',String(port)],{cwd:repo,env,windowsHide:true,stdio:['ignore','pipe','pipe']})
62
+ child.stdout.on('data',chunk=>{diagnostic=(diagnostic+chunk).slice(-20000)})
63
+ child.stderr.on('data',chunk=>{diagnostic=(diagnostic+chunk).slice(-20000)})
64
+ try{
65
+ const deadline=Date.now()+90000
66
+ while(!existsSync(resultPath)&&Date.now()<deadline&&child.exitCode===null)await new Promise(resolve=>setTimeout(resolve,250))
67
+ assert.ok(existsSync(resultPath),diagnostic.replace(/([?&]token=)[^\s]+/g,'$1<redacted>'))
68
+ const result=JSON.parse(readFileSync(resultPath,'utf8'))
69
+ assert.equal(result.success,true,result.error)
70
+ assert.equal(result.takeover.installed,true)
71
+ assert.equal(result.summaryHook.installed,true)
72
+ console.log(JSON.stringify({hostVersion:version,installation:'passed',configuration:'passed',realPresetActivation:'passed',addonSmokeChecks:'passed',modelInference:'not invoked',artifact:resultPath},null,2))
73
+ }finally{
74
+ if(child.exitCode===null)child.kill('SIGTERM')
75
+ }
@@ -0,0 +1,24 @@
1
+ /** Version evidence and runtime shape are independent checks. No network at boot. */
2
+ export function classifyHostVersion(version, policy) {
3
+ const testedVersions = policy.testedVersions ?? []
4
+ if (!version || version === 'unknown') return { status: 'unknown', version: 'unknown', testedVersions }
5
+ if (testedVersions.includes(version)) return { status: 'tested', version, testedVersions }
6
+ const family = /^([0-9]+\.[0-9]+)\./.exec(version)?.[1]
7
+ const supported = (policy.supportedSeries ?? []).includes(family)
8
+ return { status: supported ? 'untested' : 'unsupported', version, testedVersions }
9
+ }
10
+
11
+ export function inspectHostCapabilities(ctx) {
12
+ const service = name => {
13
+ try { return ctx.get?.(name) ?? ctx[name] ?? null } catch { return null }
14
+ }
15
+ const pruner = service('toolResultPruner')
16
+ const compaction = service('compaction')
17
+ return {
18
+ tools: typeof service('tools')?.register === 'function',
19
+ layer1: typeof pruner?.pruneSession === 'function' && typeof pruner?.pruneContent === 'function',
20
+ layer2: typeof compaction?.compactRegion === 'function' && typeof compaction?.summarize === 'function',
21
+ pressure: typeof service('tokenMeter')?.measure === 'function',
22
+ modelInfo: typeof service('llm')?.resolveModelInfo === 'function',
23
+ }
24
+ }
package/test/check.js CHANGED
@@ -11,6 +11,7 @@ import assert from 'node:assert/strict'
11
11
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
12
12
  import { dirname, join, resolve } from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
14
+ import { classifyHostVersion, inspectHostCapabilities } from '../src/compatibility.js'
14
15
 
15
16
  import { JevClient, JevError, estimateTokens } from '../src/jev.js'
16
17
  import { JEV_PRUNE_MARKER, countChars, decideAction, parseLimit, planTrims, pruneSessionWithJev, sliceWithBudget } from '../src/prune.js'
@@ -57,6 +58,17 @@ import {
57
58
  } from '../index.js'
58
59
 
59
60
  const here = dirname(dirname(fileURLToPath(import.meta.url)))
61
+ {
62
+ const policy = { testedVersions: ['0.1.5-rc.2'], supportedSeries: ['0.1', '0.2'] }
63
+ assert.equal(classifyHostVersion('0.1.5-rc.2', policy).status, 'tested')
64
+ assert.equal(classifyHostVersion('0.2.0-rc.2', policy).status, 'untested')
65
+ assert.equal(classifyHostVersion('0.3.0', policy).status, 'unsupported')
66
+ assert.equal(classifyHostVersion('unknown', policy).status, 'unknown')
67
+ assert.equal(classifyHostVersion('not-a-version', policy).status, 'unsupported')
68
+ assert.deepEqual(inspectHostCapabilities({get:()=>null}), {tools:false,layer1:false,layer2:false,pressure:false,modelInfo:false})
69
+ assert.equal(inspectHostCapabilities({get:()=>{throw Error('missing')},tools:{register(){}}}).tools, false)
70
+ assert.equal(inspectHostCapabilities({tools:{register(){}},compaction:{compactRegion(){},summarize(){}}}).layer2,true)
71
+ }
60
72
 
61
73
  // ---------------------------------------------------------------- token 估算
62
74
  // 常数由真实 BPE 标定(见 jev.js 的 TOKEN_ESTIMATE_CONSTANTS 注释)。
@@ -226,7 +226,7 @@ function makeCompaction(session) {
226
226
  const endIdx = nodes.indexOf(end)
227
227
  if (startIdx < 0 || endIdx < 0 || startIdx > endIdx) throw new Error(`compactRegion: 非法区间 ${start}-${end}`)
228
228
  // 关键:模拟后端内部这一次动态派发 —— 猴补丁必须在这里被调用到
229
- const summarized = await this.summarize({ messages: [] }, agent, signal)
229
+ const summarized = await this.summarize({ messages: nodes.slice(startIdx, endIdx + 1).map(seq => session.deriveEventMessage(session.eventAt(seq))).filter(message => message != null) }, agent, signal)
230
230
  const text = summarized.summary.map((b) => b.text ?? '').join('')
231
231
  calls.push({ start, end, nodes: nodes.slice(startIdx, endIdx + 1), summary: text, provider: summarized.provider })
232
232
  return {
@@ -351,7 +351,7 @@ try {
351
351
  }
352
352
 
353
353
  check('导出 name', typeof mod.name === 'string' && mod.name.length > 0, mod.name)
354
- check('导出 inject 含 tools', Array.isArray(mod.inject) && mod.inject.includes('tools'), JSON.stringify(mod.inject))
354
+ check('导出 inject 仅要求根级 tools(不等待 preset 的 pruner)', Array.isArray(mod.inject) && mod.inject.includes('tools') && !mod.inject.includes('toolResultPruner'), JSON.stringify(mod.inject))
355
355
  check('导出 apply', typeof mod.apply === 'function')
356
356
 
357
357
  // 可选的 dsh-llm 不存在时必须走可测的浅拷贝降级;恒等函数会让后续代码意外复用入参。
@@ -698,13 +698,13 @@ async function layer2Run(effectOfS2) {
698
698
  let intruderResult = null
699
699
  const innerCompact = compaction.compactRegion.bind(compaction)
700
700
  compaction.compactRegion = async (start, end, agent, signal) => {
701
- const first = await innerCompact(start, end, agent, signal)
702
- // 第一次完成后,让"别处"再发起一次压缩并观察它拿到什么 provider
701
+ await Promise.resolve()
702
+ // Competing direct summary arrives before the intended region summary.
703
703
  if (intruderResult == null) {
704
704
  intruderResult = await compaction.summarize({ messages: ['elsewhere'] }, agent, signal)
705
705
  compaction.intruderProvider = intruderResult?.provider ?? null
706
706
  }
707
- return first
707
+ return await innerCompact(start, end, agent, signal)
708
708
  }
709
709
 
710
710
  const ctx = makeCtx({ pruner, session, compaction })
@@ -1348,6 +1348,164 @@ async function layer2Run(effectOfS2) {
1348
1348
  compaction.calls.length > 0, `实际压缩 ${compaction.calls.length} 段`)
1349
1349
  }
1350
1350
 
1351
+ // Regression #48: independent summaries cannot inherit another async transaction.
1352
+ {
1353
+ const session = makeSession()
1354
+ const pruner = makePruner()
1355
+ const compaction = makeCompaction(session)
1356
+ const originalRegion = compaction.compactRegion.bind(compaction)
1357
+ let entered
1358
+ const started = new Promise(resolve => { entered = resolve })
1359
+ let release
1360
+ const barrier = new Promise(resolve => { release = resolve })
1361
+ compaction.compactRegion = async (...args) => {
1362
+ entered()
1363
+ await barrier
1364
+ return originalRegion(...args)
1365
+ }
1366
+ const ctx = makeCtx({ pruner, session, compaction })
1367
+ const judge = fakeJudge({3:0.05,5:0.05,7:0.05},{3:0.05,5:0.05,7:0.05})
1368
+ mod.apply(ctx,{...PLUGIN_CFG,compactQuantile:1,minCandidatesForRelative:3},{judge})
1369
+ const agent = {session,options:{}}
1370
+ const pending = ctx.waterfall('agent/pre-step',{agent},()=>{})
1371
+ await started
1372
+ const messages = session.surface.nodes.slice(1,-1).map(seq=>session.deriveEventMessage(session.eventAt(seq)))
1373
+ const foreign = await compaction.summarize({messages},agent)
1374
+ check('独立异步摘要即使输入相同也不能领取插件回执',foreign.provider==='fake',foreign.provider)
1375
+ release()
1376
+ await pending
1377
+ check('外部摘要先到后插件事务仍获得自己的回执',compaction.calls[0]?.provider==='jev-receipt')
1378
+ }
1379
+ // Regression #48: one apply instance may compact two sessions concurrently.
1380
+ {
1381
+ const sessionA=makeSession(), sessionB=makeSession()
1382
+ sessionB.eventAt(2).data.message.content[0].text='Different session'
1383
+ const pruner=makePruner(), compaction=makeCompaction(sessionA)
1384
+ const providers=[]
1385
+ let release
1386
+ const barrier=new Promise(resolve=>{release=resolve})
1387
+ let entered=0
1388
+ compaction.compactRegion=async(start,end,agent,signal)=>{
1389
+ if(++entered===2)release()
1390
+ await barrier
1391
+ const nodes=agent.session.surface.nodes
1392
+ const seqs=nodes.slice(nodes.indexOf(start),nodes.indexOf(end)+1)
1393
+ const input={messages:seqs.map(seq=>agent.session.deriveEventMessage(agent.session.eventAt(seq)))}
1394
+ const first=await compaction.summarize(input,agent,signal)
1395
+ const repeated=await compaction.summarize(input,agent,signal)
1396
+ providers.push([first.provider,repeated.provider])
1397
+ return {shadowedSeqs:seqs}
1398
+ }
1399
+ const ctx=makeCtx({pruner,session:sessionA,compaction})
1400
+ const judge=fakeJudge({3:0.05,5:0.05,7:0.05},{3:0.05,5:0.05,7:0.05})
1401
+ mod.apply(ctx,{...PLUGIN_CFG,compactQuantile:1,minCandidatesForRelative:3},{judge})
1402
+ await Promise.all([sessionA,sessionB].map(session=>ctx.waterfall('agent/pre-step',{agent:{session,options:{}}},()=>{})))
1403
+ check('不同会话并发压缩均只领取各自回执一次',providers.length===2&&providers.every(([a,b])=>a==='jev-receipt'&&b==='fake'),JSON.stringify(providers))
1404
+ }
1405
+ // Regression #49: goal edits invalidate result but not effect, including replacements.
1406
+ {
1407
+ const session=makeSession(), pruner=makePruner(), compaction=makeCompaction(session)
1408
+ const ctx=makeCtx({pruner,session,compaction})
1409
+ const result={3:0.05,5:0.05,7:0.05}
1410
+ const judge=fakeJudge(result,{3:0.05,5:0.05,7:0.05})
1411
+ judge.asked=[]
1412
+ const ask=judge.ask.bind(judge)
1413
+ judge.ask=async(state,questions)=>{judge.asked.push(Object.keys(questions));return ask(state,questions)}
1414
+ mod.apply(ctx,{...PLUGIN_CFG,compactOn:'off'},{judge})
1415
+ const exec={agent:{session,options:{}}}
1416
+ await ctx.waterfall('agent/pre-step',exec,()=>{})
1417
+ await ctx.waterfall('agent/pre-step',exec,()=>{})
1418
+ check('目标不变不重复判定',judge.requests===1)
1419
+ session.append('user/message',{content:[{type:'text',text:'Use the earlier read for a new task'}],source:{kind:'user'}},{surfaceOp:'append'})
1420
+ check('新目标出现后同步裁剪不能沿用旧低分',pruner.pruneSession(session).pruned.length===0)
1421
+ await ctx.waterfall('agent/pre-step',exec,()=>{})
1422
+ check('新目标只补判 result 轴,保留已有 effect',judge.asked[1]?.length===3&&judge.asked[1].every(id=>id.startsWith('result_s')),JSON.stringify(judge.asked[1]))
1423
+ const trimmed=pruner.pruneSession(session)
1424
+ check('补判完成后第一层可生成 replacement',trimmed.pruned.length===3)
1425
+ for (const row of trimmed.pruned) result[row.replacementSeq]=0.95
1426
+ session.append('user/message',{content:[{type:'text',text:'Another goal requiring old results'}],source:{kind:'user'}},{surfaceOp:'append'})
1427
+ await ctx.waterfall('agent/pre-step',exec,()=>{})
1428
+ check('replacement 继承的旧 result 被失效并重新请求',judge.asked[2]?.length===3&&judge.asked[2].every(id=>id.startsWith('result_s')),JSON.stringify(judge.asked[2]))
1429
+ const newest=session.eventAt(session.surface.nodes.at(-1))
1430
+ newest.data.content[0].text='x'.repeat(600)+'old suffix'
1431
+ await ctx.waterfall('agent/pre-step',exec,()=>{})
1432
+ const requestsBeforeEdit=judge.requests
1433
+ newest.data.content[0].text='x'.repeat(600)+'new suffix'
1434
+ await ctx.waterfall('agent/pre-step',exec,()=>{})
1435
+ check('同 seq 指令在 500 字之后变化也使缓存失效',judge.requests===requestsBeforeEdit+1)
1436
+ }
1437
+ // Regression #49: late answers must not repopulate a stale-goal cache.
1438
+ {
1439
+ const session=makeSession(), pruner=makePruner(), compaction=makeCompaction(session)
1440
+ const ctx=makeCtx({pruner,session,compaction})
1441
+ const judge=fakeJudge({3:0.05,5:0.05,7:0.05},{3:0.05,5:0.05,7:0.05})
1442
+ const ask=judge.ask.bind(judge)
1443
+ judge.ask=async(state,questions)=>{
1444
+ const answer=await ask(state,questions)
1445
+ if(judge.requests===1)session.append('user/message',{content:[{type:'text',text:'Goal changed during request'}],source:{kind:'user'}},{surfaceOp:'append'})
1446
+ return answer
1447
+ }
1448
+ mod.apply(ctx,{...PLUGIN_CFG,compactOn:'off'},{judge})
1449
+ const exec={agent:{session,options:{}}}
1450
+ await ctx.waterfall('agent/pre-step',exec,()=>{})
1451
+ check('请求中目标变化时不写入旧目标低分',pruner.pruneSession(session).pruned.length===0)
1452
+ await ctx.waterfall('agent/pre-step',exec,()=>{})
1453
+ check('下一轮仍请求新目标判定',judge.requests===2)
1454
+ }
1455
+ // Review follow-up: host-owned nested regions and leading system messages.
1456
+ {
1457
+ const session=makeSession(),pruner=makePruner(),compaction=makeCompaction(session)
1458
+ let own=false
1459
+ let nestedProvider
1460
+ let canceledProvider
1461
+ compaction.compactRegion=async(start,end,agent,signal)=>{
1462
+ const nodes=session.surface.nodes
1463
+ const seqs=nodes.slice(nodes.indexOf(start),nodes.indexOf(end)+1)
1464
+ const messages=seqs.map(seq=>session.deriveEventMessage(session.eventAt(seq)))
1465
+ if(!own){
1466
+ own=true
1467
+ // This call uses the public entry and must clear the inherited owner.
1468
+ nestedProvider=(await compaction.compactRegion(start,end,agent,signal)).provider
1469
+ const abort=new AbortController();abort.abort()
1470
+ canceledProvider=(await compaction.summarize({messages},agent,abort.signal)).provider
1471
+ const result=await compaction.summarize({messages:[{role:'system',content:[]},...messages]},agent,signal)
1472
+ check('合法 system 前缀仍允许事务回执',result.provider==='jev-receipt')
1473
+ return {shadowedSeqs:seqs}
1474
+ }
1475
+ return compaction.summarize({messages},agent,signal)
1476
+ }
1477
+ const ctx=makeCtx({pruner,session,compaction})
1478
+ const judge=fakeJudge({3:0.05,5:0.05,7:0.05},{3:0.05,5:0.05,7:0.05})
1479
+ mod.apply(ctx,{...PLUGIN_CFG,compactQuantile:1,minCandidatesForRelative:3},{judge})
1480
+ await ctx.waterfall('agent/pre-step',{agent:{session,options:{}}},()=>{})
1481
+ check('嵌套的宿主 region 不继承插件回执归属',nestedProvider==='fake',nestedProvider)
1482
+ check('已中断摘要不能消耗回执',canceledProvider==='fake',canceledProvider)
1483
+ }
1484
+ // ---------------------------------------------------------------- 汇总
1485
+ {
1486
+ const sessionA=makeSession(),sessionB=makeSession()
1487
+ const prunerA=makePruner(),prunerB=makePruner()
1488
+ const compactionA=makeCompaction(sessionA),compactionB=makeCompaction(sessionB)
1489
+ const ctx=makeCtx({pruner:prunerA,session:sessionA,compaction:compactionA})
1490
+ const get=ctx.get
1491
+ const meter=ctx.tokenMeter
1492
+ delete ctx.compaction
1493
+ delete ctx.tokenMeter
1494
+ ctx.get=name=>['toolResultPruner','compaction','tokenMeter'].includes(name)?null:get(name)
1495
+ const originalA=prunerA.pruneSession,originalB=prunerB.pruneSession
1496
+ const services=new WeakMap()
1497
+ const agentA={ctx:{},session:sessionA,options:{}},agentB={ctx:{},session:sessionB,options:{}}
1498
+ services.set(agentA,{toolResultPruner:prunerA,compaction:compactionA,tokenMeter:meter})
1499
+ services.set(agentB,{toolResultPruner:prunerB,compaction:compactionB,tokenMeter:meter})
1500
+ const judge=fakeJudge({3:0.05,5:0.05,7:0.05},{3:0.05,5:0.05,7:0.05})
1501
+ mod.apply(ctx,{...PLUGIN_CFG,compactQuantile:1,minCandidatesForRelative:3},
1502
+ {judge,serviceForAgent:(_ctx,agent,name)=>services.get(agent)?.[name]})
1503
+ check('根级无 pruner 时插件仍注册执行前钩子',ctx.handlers.get('agent/pre-step')?.length===2)
1504
+ await ctx.waterfall('agent/pre-step',{agent:agentA},()=>{})
1505
+ await ctx.waterfall('agent/pre-step',{agent:agentB},()=>{})
1506
+ check('两个 preset 的 pruner 分别接管',prunerA.pruneSession!==originalA&&prunerB.pruneSession!==originalB)
1507
+ check('两个 preset 的回执进入各自引擎',compactionA.calls[0]?.provider==='jev-receipt'&&compactionB.calls[0]?.provider==='jev-receipt')
1508
+ }
1351
1509
  // ---------------------------------------------------------------- 汇总
1352
1510
  console.log()
1353
1511
  for (const r of results) console.log(`${r.ok ? ' ✅' : ' ❌'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`)