dsh-command-context-trim 0.1.0 → 0.2.0
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 +72 -1
- package/README.md +105 -9
- package/README.zh.md +47 -4
- package/cordis.patch.yml +6 -0
- package/lib/apply.js +4 -2
- package/lib/auto-trim.js +87 -0
- package/lib/config.js +13 -3
- package/lib/index.js +27 -145
- package/lib/plan.js +142 -22
- package/lib/render.js +115 -0
- package/lib/session-compat.js +80 -0
- package/lib/target.js +18 -2
- package/lib/trim-session.js +129 -0
- package/package.json +14 -11
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,75 @@ All notable changes to this project are documented here. This project adheres to
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-09-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Automatic trimming on the context wall.** A `prepend`ed `agent/request-error` listener reacts to
|
|
13
|
+
`CONTEXT_WINDOW_EXCEEDED`, frees space with no model call, and asks the loop to retry. Only when it cannot free
|
|
14
|
+
anything does the waterfall continue into DSH's own recovery (prune + summarize) — so a session that hits the wall
|
|
15
|
+
is repaired by *dropping* the oldest span first and only pays for summarization when dropping cannot help.
|
|
16
|
+
This is the unattended form of `/trim`; it is the same execution, invoked by the harness instead of a human.
|
|
17
|
+
- Configuration `autoTrim` (default `true`) and `maxAutoTrimRetries` (default `1`, per overflow episode).
|
|
18
|
+
- **The elided span is chosen by explicit preference tiers.** Elision always starts at the oldest balanced cut, and the
|
|
19
|
+
search is graded: (1) stay outside the retained tail and keep the final message, with the configured retention relaxed
|
|
20
|
+
step by step only if the fit otherwise fails; (2) reach into the retained tail, still keeping the final message;
|
|
21
|
+
(3) last resort — include the final message, typically the current step's assistant tool-call plus its tool result,
|
|
22
|
+
which can only be removed as a pair. `allowTailTrim: false` ends the list after tier 1. The plan and every rendered
|
|
23
|
+
result state when the last-resort tier was used.
|
|
24
|
+
- **Planner anchor changed: the newest `user/message` is protected, the final node is not.** The previous rule
|
|
25
|
+
("never elide the final surface node") deadlocked the most common overflow shape — one large assistant tool-call whose
|
|
26
|
+
tool result is the last node could not be removed as a pair, so only a handful of tokens were freeable while the
|
|
27
|
+
request stayed over the wall (observed live: "largest balanced span frees ~4 of the ~4631 tokens needed"). The newest
|
|
28
|
+
human instruction is now a **barrier** (never elided, never crossed) and everything after it stays eligible, tool
|
|
29
|
+
pairing still enforced on both cut edges.
|
|
30
|
+
|
|
31
|
+
### Notes
|
|
32
|
+
|
|
33
|
+
- **Scope: the context wall only.** The listener fires exclusively for `CONTEXT_WINDOW_EXCEEDED`. Ordinary
|
|
34
|
+
threshold compaction (`agent/pre-step` pressure), `/compact`, and the tool-result pruner are untouched — this is
|
|
35
|
+
asserted by a test that pins the registered listener set.
|
|
36
|
+
- Why `prepend` is required: `agent/request-error` is a Cordis waterfall and compaction registers its summarization
|
|
37
|
+
recovery on the same event. Cordis stores listeners in registration order and `{ prepend: true }` unshifts, so this
|
|
38
|
+
listener runs first even when compaction is mounted later inside an agent-preset isolate realm (as it is in a web
|
|
39
|
+
profile). Returning `{ kind: 'retry' }` without calling `next()` vetoes summarization for that attempt.
|
|
40
|
+
- The per-episode budget resets when a completed assistant message lands or the agent goes idle, mirroring
|
|
41
|
+
compaction's own overflow accounting.
|
|
42
|
+
- Trade-off, stated plainly: an automatic trim **drops** the oldest span rather than summarizing it. That is the
|
|
43
|
+
point on a small local window — the summarizer must fit the region it is condensing and frequently cannot — but it
|
|
44
|
+
does mean the dropped text is replaced by a marker instead of a summary. `/compact` remains available, and the full
|
|
45
|
+
text stays in the durable session log.
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
## [0.1.1] - 2026-09-15
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **DeepSeek Harness 0.1.5 compatibility.** 0.1.5 renamed the positional replacement marker
|
|
53
|
+
(`{op: 'replace', start, end}` → `{op: 'replace', startSeq, endSeq}`), so every trim failed with
|
|
54
|
+
`session event "user/message" carries an invalid replace surfaceOp`. The plugin now probes the accepted shape
|
|
55
|
+
once against the harness actually installed and writes that one, so the same build works on the 0.1.2 and
|
|
56
|
+
0.1.5 lines without a version check.
|
|
57
|
+
- **The system prompt is never trimmed — and no longer eats head protection.** 0.1.5 moved the system prompt
|
|
58
|
+
from the request header onto the surface as node 0 (`system/message`). A position-only "protect the first
|
|
59
|
+
node" rule would have protected the *system prompt* and exposed the **user's original request** as the first
|
|
60
|
+
elidable message. System nodes are now barriers: never elided and never crossed, and `protectHeadNodes` counts
|
|
61
|
+
only non-barrier nodes, so it keeps protecting the task statement.
|
|
62
|
+
|
|
63
|
+
### Notes
|
|
64
|
+
|
|
65
|
+
- No configuration changes. The only user-visible difference is the "fixed request overhead" refusal, which now
|
|
66
|
+
says "tool schemas and other non-surface request data": on 0.1.5 the system prompt is surface content rather
|
|
67
|
+
than header content.
|
|
68
|
+
- Verified on harness 0.1.2-rc.1 and 0.1.5-rc.2 (40 tests each, same build).
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
- Integration tests against the **real** `ctx.tokenMeter` (bare cordis context + stub projection registry): a trim's
|
|
74
|
+
measured saving equals the `compaction/prune` shadow price it claims minus the replacement marker, and a *fresh* meter
|
|
75
|
+
folding the replayed log reaches the identical total — the replay property the claim exists for.
|
|
76
|
+
|
|
8
77
|
## [0.1.0] - 2026-09-15
|
|
9
78
|
|
|
10
79
|
### Added
|
|
@@ -32,5 +101,7 @@ All notable changes to this project are documented here. This project adheres to
|
|
|
32
101
|
content stays in the durable session log. v1 has no `/untrim`.
|
|
33
102
|
- Requires a harness that exposes `ctx.commands`, `ctx.tokenMeter`, and `ctx.llm` (DeepSeek Harness 0.1.2-rc.1 or later).
|
|
34
103
|
|
|
35
|
-
[Unreleased]: https://github.com/snailium/dsh-command-context-trim/compare/v0.
|
|
104
|
+
[Unreleased]: https://github.com/snailium/dsh-command-context-trim/compare/v0.2.0...HEAD
|
|
105
|
+
[0.2.0]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.1...v0.2.0
|
|
106
|
+
[0.1.1]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.0...v0.1.1
|
|
36
107
|
[0.1.0]: https://github.com/snailium/dsh-command-context-trim/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ synchronous appends, zero LLM calls — it works precisely when every request is
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
# from npm
|
|
25
|
+
# from npm
|
|
26
26
|
dsh plugin --profile web add dsh-command-context-trim
|
|
27
27
|
|
|
28
28
|
# from a checkout
|
|
@@ -78,21 +78,92 @@ Design consequences:
|
|
|
78
78
|
original content stays in the durable session log, so a trim is auditable and recoverable by hand.
|
|
79
79
|
- **No tool-call/result pair is ever split.** Cut edges are chosen with
|
|
80
80
|
`toolPairingBalancedBefore`/`After` from `@deepseek-ai/dsh-compaction`.
|
|
81
|
+
- **The system prompt is never trimmed** (harness 0.1.5+ carries it as a surface node): it is a barrier that no
|
|
82
|
+
elided span may touch or cross.
|
|
81
83
|
- **Mutual exclusion with everything else.** The handler runs inside `agent.runMaintenance()`, which fails unless the agent
|
|
82
84
|
is idle, so it cannot interleave with a turn, `/compact`, or automatic compaction; it also refuses while an unmatched
|
|
83
85
|
`compaction/start` is open.
|
|
84
86
|
|
|
87
|
+
## Automatic trimming on the context wall
|
|
88
|
+
|
|
89
|
+
`autoTrim` (default on) makes the same model-free reduction happen without anyone typing a command: a **prepended**
|
|
90
|
+
`agent/request-error` listener reacts to `CONTEXT_WINDOW_EXCEEDED`, trims, and asks the loop to retry.
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
request fails (context wall)
|
|
94
|
+
├─ prepended: context-trim → trim a span, no model call → retry ← wins when it can free space
|
|
95
|
+
└─ next(): compaction-basic → prune tool results → summarize (LLM) ← only when trimming cannot help
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Why `prepend` is the whole trick: `agent/request-error` is a Cordis **waterfall**, and compaction registers its own
|
|
99
|
+
summarization recovery on the same event. Cordis keeps listeners in registration order and `{ prepend: true }`
|
|
100
|
+
unshifts to the front, so this plugin runs first even though compaction is mounted later — in a web profile it lives
|
|
101
|
+
inside an agent-preset isolate realm, which no host-plane plugin can out-order by mount position. Returning
|
|
102
|
+
`{ kind: 'retry' }` without calling `next()` vetoes summarization for that attempt.
|
|
103
|
+
|
|
104
|
+
Scope, deliberately narrow:
|
|
105
|
+
|
|
106
|
+
| Event | Behaviour |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `CONTEXT_WINDOW_EXCEEDED` on `agent/request-error` | trim, then retry |
|
|
109
|
+
| any other request failure | untouched (`next()`) |
|
|
110
|
+
| ordinary threshold compaction (`agent/pre-step` pressure) | **never touched** |
|
|
111
|
+
| `/compact`, the tool-result pruner | **never touched** |
|
|
112
|
+
|
|
113
|
+
The per-episode retry budget (`maxAutoTrimRetries`, default 1) resets when a completed assistant message lands or the
|
|
114
|
+
agent goes idle, mirroring compaction's own overflow accounting. Set `autoTrim: false` to keep trimming manual.
|
|
115
|
+
|
|
116
|
+
Trade-off, stated plainly: an automatic trim **drops** the oldest span instead of summarizing it. On a small local
|
|
117
|
+
window that is the point — the summarizer must fit the region it is condensing and frequently cannot — but the dropped
|
|
118
|
+
text is replaced by a marker rather than a summary. `/compact` stays available, and the full text remains in the
|
|
119
|
+
durable session log.
|
|
120
|
+
|
|
85
121
|
## What is protected
|
|
86
122
|
|
|
87
123
|
| Protected | Why |
|
|
88
124
|
|---|---|
|
|
89
125
|
| Leading `protectHeadNodes` nodes (default 1) | The task statement — dropping it destroys the point of the conversation. |
|
|
90
126
|
| Recent tail (`retainRatio` of the window, floor `minTailTokens`) | Recency is what a coding agent needs; retention is relaxed only when the fit is otherwise impossible, and the result says so. |
|
|
91
|
-
| The
|
|
127
|
+
| The **newest** `user/message` | The live human instruction. It is never elided and no span may cross it, so an ongoing request cannot be dropped. Older user messages are ordinary nodes. |
|
|
128
|
+
| The final surface message | **A preference, not a prohibition.** Kept whenever any older span can free enough; dropped only as a last resort, and typically only together with its tool call (they can only be removed as a pair). |
|
|
92
129
|
|
|
93
130
|
Within those bounds the policy is **oldest-first, least-long-possible**: the elided span starts at the oldest balanced cut
|
|
94
131
|
and grows only until it frees exactly enough tokens.
|
|
95
132
|
|
|
133
|
+
Elision always starts at the **oldest** balanced cut, and the search is graded so that the cheapest loss is tried first:
|
|
134
|
+
|
|
135
|
+
1. a span that stays **outside the retained tail** and keeps the **final message** (the configured retention, relaxed step
|
|
136
|
+
by step only if the fit otherwise fails);
|
|
137
|
+
2. a span that may reach **into the retained tail**, still keeping the final message;
|
|
138
|
+
3. **last resort** — a span that includes the final message, typically the current step's assistant tool-call plus its tool
|
|
139
|
+
result, which can only be removed as a pair.
|
|
140
|
+
|
|
141
|
+
Protecting the final message outright deadlocks the most common overflow shape: one large assistant tool-call whose tool
|
|
142
|
+
result is the last node cannot be removed as a pair, which left a handful of freeable tokens while the request stayed over
|
|
143
|
+
the wall (observed live: "largest balanced span frees ~4 of the ~4631 tokens needed"). The newest user message is the real
|
|
144
|
+
anchor and stays a hard barrier in every tier. With `allowTailTrim: false` the search ends after tier 1, so the retained
|
|
145
|
+
tail is a hard boundary and the final message is never dropped.
|
|
146
|
+
|
|
147
|
+
## Compatibility
|
|
148
|
+
|
|
149
|
+
| Harness | State |
|
|
150
|
+
|---|---|
|
|
151
|
+
| 0.1.2-rc.1 (`latest`) | ✅ full suite green |
|
|
152
|
+
| 0.1.5-rc.2 (`next`) | ✅ full suite green |
|
|
153
|
+
|
|
154
|
+
Two harness changes between those lines are handled without a version check:
|
|
155
|
+
|
|
156
|
+
- **The replacement marker was renamed** — `{op: 'replace', start, end}` became `{op: 'replace', startSeq, endSeq}`.
|
|
157
|
+
The plugin probes a throwaway detached session with each known shape at first use and writes the accepted one.
|
|
158
|
+
- **The system prompt moved onto the surface** — 0.1.5 carries it as `system/message` node 0 instead of
|
|
159
|
+
`header.system`. System nodes are treated as **barriers**: they are never elided, no elided span crosses one,
|
|
160
|
+
and `protectHeadNodes` counts only non-barrier nodes, so head protection keeps covering the task statement
|
|
161
|
+
rather than the system prompt.
|
|
162
|
+
|
|
163
|
+
Because of the second change, the fixed request overhead is now the tool schemas plus any other non-surface
|
|
164
|
+
request data; on 0.1.2 it also included the system prompt. A trim's budget itself is unaffected — it comes from
|
|
165
|
+
the token meter's total, whichever way the harness splits that total.
|
|
166
|
+
|
|
96
167
|
## Configuration
|
|
97
168
|
|
|
98
169
|
Override on the `context-trim` row of a profile patch (the bundle's own `cordis.patch.yml` lists the full default set):
|
|
@@ -104,8 +175,10 @@ Override on the `context-trim` row of a profile patch (the bundle's own `cordis.
|
|
|
104
175
|
| `retainRatio` / `retainTokens` | `0.16` / — | Recent tail kept verbatim (mutually exclusive forms) |
|
|
105
176
|
| `minTailTokens` | `2048` | Absolute floor for that tail |
|
|
106
177
|
| `protectHeadNodes` | `1` | Leading nodes that are never trimmed |
|
|
107
|
-
| `allowTailTrim` | `true` |
|
|
178
|
+
| `allowTailTrim` | `true` | Enable tiers 2–3 (reach into the retained tail; as a last resort include the final message). `false` ends the search after tier 1, making the retained tail a hard boundary |
|
|
108
179
|
| `markerSlackTokens` | `64` | Slack added to the priced marker so the post-trim request stays under budget |
|
|
180
|
+
| `autoTrim` | `true` | Trim automatically on `CONTEXT_WINDOW_EXCEEDED`; never fires on ordinary compaction |
|
|
181
|
+
| `maxAutoTrimRetries` | `1` | Automatic trims allowed per overflow episode before compaction takes over |
|
|
109
182
|
|
|
110
183
|
## Limits
|
|
111
184
|
|
|
@@ -121,6 +194,23 @@ Override on the `context-trim` row of a profile patch (the bundle's own `cordis.
|
|
|
121
194
|
|
|
122
195
|
## Development
|
|
123
196
|
|
|
197
|
+
### End-to-end overflow check (no model needed)
|
|
198
|
+
|
|
199
|
+
`scripts/mock-overflow-server.mjs` is a stateful OpenAI-compatible endpoint that enforces a **real** limit lower than the
|
|
200
|
+
`contextWindow` the harness is told, and answers the first `TOOL_STEPS` requests with a tool call so one turn keeps
|
|
201
|
+
looping and grows past the real limit — the context wall, without a model switch:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
node scripts/mock-overflow-server.mjs & # PORT=4185 TOKEN_LIMIT=12000 TOOL_STEPS=4
|
|
205
|
+
# point an ISOLATED profile at it (provider with contextWindow 20000, baseURL .../v1), then:
|
|
206
|
+
DSH_HOME=$(mktemp -d) dsh --profile headless "..." # see the isolated-home procedure in dsh-plugin-packaging
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
A passing run leaves this in the session log: `assistant/attempt` (the wall), then exactly one `compaction/prune` + one
|
|
210
|
+
`user/message` replacement, then a **succeeding** retry — and **zero** `compaction/start`, proving the request was
|
|
211
|
+
repaired by trimming and that summarisation never ran.
|
|
212
|
+
|
|
213
|
+
|
|
124
214
|
```bash
|
|
125
215
|
npm install # the harness contracts this plugin builds on, pinned as devDependencies
|
|
126
216
|
npm test # node --test
|
|
@@ -128,21 +218,27 @@ npm run link:harness # or resolve @deepseek-ai from a local dsh installation i
|
|
|
128
218
|
```
|
|
129
219
|
|
|
130
220
|
Tests cover the pure planner and argument parser, the surface mutation against a real `Session` (including log replay),
|
|
131
|
-
|
|
221
|
+
the plugin's command registration and end-to-end trim over a stub context, and — against the real `ctx.tokenMeter` — that a
|
|
222
|
+
trim's measured saving equals the shadow price it claims and that a fresh meter replaying the trimmed log lands on the very
|
|
223
|
+
same total. CI runs the suite on Node 22 and 24;
|
|
132
224
|
releases go out through `.github/workflows/publish.yml`, which is manual-only (`workflow_dispatch`).
|
|
133
225
|
|
|
134
226
|
### Verification status
|
|
135
227
|
|
|
136
228
|
| Check | State |
|
|
137
229
|
|---|---|
|
|
138
|
-
| `npm test` (
|
|
230
|
+
| `npm test` (51 tests: planner, args, surface apply + log replay, plugin handler, automatic overflow path) | ✅ passing |
|
|
139
231
|
| Isolated `DSH_HOME` install (`dsh plugin add file:…`) reconciling dependency **and** bundle layer | ✅ verified |
|
|
140
232
|
| Composed profile tree contains the `context-trim` insert row (`dsh --dump-config`) | ✅ verified |
|
|
141
233
|
| Profile boot with the plugin mounted (no load error) | ✅ reaches the credential check cleanly |
|
|
142
|
-
| Same suite against the pinned **published** harness packages (`npm ci`) | ✅
|
|
143
|
-
|
|
|
144
|
-
|
|
|
145
|
-
|
|
|
234
|
+
| Same suite against the pinned **published** harness packages (`npm ci`) | ✅ 51 passing |
|
|
235
|
+
| Integration against the **real** `ctx.tokenMeter`: measured drop equals the claimed shadow price, and a fresh meter replaying the trimmed log reaches the identical total | ✅ 4 tests |
|
|
236
|
+
| Real-`cordis` proof that a `prepend`ed waterfall listener runs first and vetoes the chain (the mechanism the automatic path depends on) | ✅ 3 tests |
|
|
237
|
+
| Same suite on harness 0.1.5-rc.2 (renamed marker + surface system prompt) | ✅ 51 passing |
|
|
238
|
+
| CI workflow (Node 22 / 24) | ✅ green |
|
|
239
|
+
| npm release via GitHub Actions | ✅ 0.1.0 published with provenance (`+ dsh-command-context-trim@0.1.0`) |
|
|
240
|
+
| Isolated profile install **from the npm registry** (dependency + bundle layer + composed insert row) | ✅ 0.1.0 |
|
|
241
|
+
| End-to-end in the web GUI against a small-window model | ⏳ harness ready, not yet run |
|
|
146
242
|
|
|
147
243
|
## License
|
|
148
244
|
|
package/README.zh.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
## 安装
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
dsh plugin --profile web add dsh-command-context-trim #
|
|
20
|
+
dsh plugin --profile web add dsh-command-context-trim # 从 npm 安装
|
|
21
21
|
dsh plugin --profile web add file:/path/to/dsh-command-context-trim # 从源码
|
|
22
22
|
```
|
|
23
23
|
|
|
@@ -53,11 +53,54 @@ dsh plugin --profile web add file:/path/to/dsh-command-context-trim # 从源
|
|
|
53
53
|
- **与其它机制互斥**:命令在 `agent.runMaintenance()` 内执行(非 idle 直接失败),不会与回合、`/compact`、自动压缩交错;
|
|
54
54
|
存在未闭合的 `compaction/start` 时也会拒绝执行。
|
|
55
55
|
|
|
56
|
+
## 撞墙自动 trim
|
|
57
|
+
|
|
58
|
+
`autoTrim`(默认开)让同一套"无模型调用"的裁剪在无人值守时发生:用 **`prepend`** 注册的 `agent/request-error`
|
|
59
|
+
监听器在 `CONTEXT_WINDOW_EXCEEDED` 时裁剪并请求重试。
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
请求撞墙
|
|
63
|
+
├─ prepend: context-trim → 裁剪一段,零模型调用 → retry ← 能腾出空间时由它解决
|
|
64
|
+
└─ next(): compaction-basic → 先 prune 工具结果再摘要(LLM) ← 只在 trim 无能为力时
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
为什么 `prepend` 是关键:`agent/request-error` 是 Cordis 的 **waterfall**,compaction 也在同一事件上注册了自己的
|
|
68
|
+
摘要恢复;Cordis 按注册顺序存放监听器,`{ prepend: true }` 会 `unshift` 到最前,所以即使 compaction 是稍后在
|
|
69
|
+
agent-preset 的 isolate realm 里挂载的(web profile 里就是这样),本插件依然先执行。不调用 `next()` 即否决该次
|
|
70
|
+
摘要。
|
|
71
|
+
|
|
72
|
+
**范围刻意收窄**:只有 `CONTEXT_WINDOW_EXCEEDED` 才触发;其它请求错误、普通阈值 compaction(`agent/pre-step` 压力路径)、
|
|
73
|
+
`/compact`、工具结果 pruner **一律不碰**(有测试锁定注册的监听器集合)。
|
|
74
|
+
|
|
75
|
+
每轮溢出 epis 的额度由 `maxAutoTrimRetries`(默认 1)限制,收到完成的 assistant 消息或 agent 空闲即重置。
|
|
76
|
+
代价如实说:自动 trim 是**丢弃**最旧一段而不是摘要它——在小窗口下这正是要点,但被丢的内容只会变成一条占位标记。
|
|
77
|
+
`/compact` 仍在,原文也仍在会话日志里。
|
|
78
|
+
|
|
56
79
|
## 保护集与选段策略
|
|
57
80
|
|
|
58
|
-
|
|
81
|
+
保护与**优先级**(永远从最旧处开始裁,按"损失最小"逐档尝试):
|
|
82
|
+
|
|
83
|
+
1. 留在保留尾部之外,且**保留最后一条**(先按配置的保留量;实在放不下才逐级放宽保留量);
|
|
84
|
+
2. 可以进入保留尾部,但仍**保留最后一条**;
|
|
85
|
+
3. **最后一档**才允许把最后一条纳入——通常就是当前这步的 assistant tool-call 与它的 tool-result(两者只能成对移除)。
|
|
86
|
+
|
|
87
|
+
另外:开头 `protectHeadNodes`(默认 1,即任务声明)与**最新的那条 `user/message`(你当前的指令)是硬屏障**——永不裁剪、也不被跨越。把最后一条按位置硬保护会卡死最常见的溢出形态(实测探针:`largest balanced span frees ~4 of the ~4631 tokens needed`)。`allowTailTrim: false` 时搜索在第 1 档后结束:保留尾部成为硬边界,最后一条永不丢弃。
|
|
59
88
|
在保护集之间采用**最旧优先、够用即止**:从最旧的平衡切点开始,只增长到刚好释放够 token。
|
|
60
89
|
|
|
90
|
+
## 兼容性
|
|
91
|
+
|
|
92
|
+
| Harness | 状态 |
|
|
93
|
+
|---|---|
|
|
94
|
+
| 0.1.2-rc.1(`latest`) | ✅ 全套测试通过 |
|
|
95
|
+
| 0.1.5-rc.2(`next`) | ✅ 全套测试通过 |
|
|
96
|
+
|
|
97
|
+
两处 0.1.5 变更已在不做版本号判断的前提下兼容:
|
|
98
|
+
|
|
99
|
+
- **替换标记改名**:`{op:'replace', start, end}` → `{op:'replace', startSeq, endSeq}`。插件首次使用时用一个一次性游离 session 探测本机 harness 接受哪种形状,再按该形状写入。
|
|
100
|
+
- **system prompt 从 header 搬到了 surface**(0.1.5 作为 `system/message` 节点 0)。system 节点被当作**屏障**:永不裁剪、任何被裁区间都不得跨越它;同时 `protectHeadNodes` 只统计非屏障节点,因此"保护头部"保护的仍是**用户的任务声明**,而不是 system prompt。
|
|
101
|
+
|
|
102
|
+
因此"固定请求开销"的含义变为工具 schema + 其它非 surface 请求数据(0.1.2 上还包含 system prompt);裁剪预算本身不受影响,因为它来自 token meter 的总量。
|
|
103
|
+
|
|
61
104
|
## 配置
|
|
62
105
|
|
|
63
106
|
在 profile patch 的 `context-trim` 行上覆盖(`cordis.patch.yml` 里列出了全部默认值):
|
|
@@ -82,10 +125,10 @@ npm test # node --test
|
|
|
82
125
|
npm run link:harness # 也可改为从本地 dsh 安装的依赖闭包解析 @deepseek-ai
|
|
83
126
|
```
|
|
84
127
|
|
|
85
|
-
已验证:
|
|
128
|
+
已验证:34 个测试全部通过(选段算法、参数解析、真实 Session 上的 surface 改写与日志重放、插件命令注册与端到端裁剪,以及用**真实 `ctx.tokenMeter`** 验证「实测降幅 == 声明的 shadow price」和「新进程重放裁剪后日志得到完全一致的总量」);
|
|
86
129
|
隔离 `DSH_HOME` 安装后 dependency 与 bundle 层均正确 reconcile;`dsh --dump-config` 中出现 `context-trim` 行;profile 启动无加载错误。
|
|
87
130
|
CI 在 Node 22/24 上跑同一套测试;发布通过 `.github/workflows/publish.yml`(手动 `workflow_dispatch`)。
|
|
88
|
-
|
|
131
|
+
npm 0.2.0(新增撞墙自动 trim),并已在隔离 profile 里从 registry 安装验证;尚未执行:Web GUI 里的真实小窗口端到端验证(mock provider 与隔离实例已就绪)。
|
|
89
132
|
|
|
90
133
|
## License
|
|
91
134
|
|
package/cordis.patch.yml
CHANGED
|
@@ -27,3 +27,9 @@
|
|
|
27
27
|
# Slack added to the priced replacement marker, so the post-trim request
|
|
28
28
|
# stays under budget even though the marker text carries real numbers.
|
|
29
29
|
markerSlackTokens: 64
|
|
30
|
+
# Trim automatically when a request hits the model's context wall
|
|
31
|
+
# (CONTEXT_WINDOW_EXCEEDED) instead of going straight to compaction.
|
|
32
|
+
# Ordinary threshold compaction is never touched.
|
|
33
|
+
autoTrim: true
|
|
34
|
+
# Automatic trims allowed per overflow episode before compaction takes over.
|
|
35
|
+
maxAutoTrimRetries: 1
|
package/lib/apply.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* @module dsh-command-context-trim/apply
|
|
22
22
|
*/
|
|
23
23
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
24
|
+
import { replacementOp } from './session-compat.js';
|
|
24
25
|
|
|
25
26
|
/** Plugin name written into every replacement message's source marker. */
|
|
26
27
|
export const TRIM_PLUGIN = 'dsh-command-context-trim';
|
|
@@ -81,17 +82,18 @@ export function isTrimMarkerSource(source) {
|
|
|
81
82
|
* @param session - session whose surface is rewritten.
|
|
82
83
|
* @param plan - committed span plan naming the shadowed range.
|
|
83
84
|
* @param marker - replacement message built for that plan.
|
|
85
|
+
* @param keys - replacement-marker keys this harness accepts ({@link replaceKeys}).
|
|
84
86
|
* @returns the appended replacement event.
|
|
85
87
|
* @throws when the session rejects the append (surface contract violation).
|
|
86
88
|
*/
|
|
87
|
-
export function applyTrim(session, plan, marker) {
|
|
89
|
+
export function applyTrim(session, plan, marker, keys) {
|
|
88
90
|
session.append('compaction/prune', {
|
|
89
91
|
shadowedRange: { start: plan.startSeq, end: plan.endSeq },
|
|
90
92
|
shadowedSeqs: [...plan.shadowedSeqs],
|
|
91
93
|
shadowedTokenCount: plan.shadowedTokens
|
|
92
94
|
});
|
|
93
95
|
return session.append('user/message', marker, {
|
|
94
|
-
surfaceOp:
|
|
96
|
+
surfaceOp: replacementOp(keys, plan.startSeq, plan.endSeq),
|
|
95
97
|
sourceEventSeqs: [...plan.shadowedSeqs]
|
|
96
98
|
});
|
|
97
99
|
}
|
package/lib/auto-trim.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic trimming when a request hits the model's context wall.
|
|
3
|
+
*
|
|
4
|
+
* This is the unattended neighbour of `/trim`: a **prepended**
|
|
5
|
+
* `agent/request-error` listener reacts to `CONTEXT_WINDOW_EXCEEDED`, frees space
|
|
6
|
+
* with no model call, and asks the loop to retry. Only when it cannot help does
|
|
7
|
+
* the waterfall continue into DSH's own recovery — which prunes oversized tool
|
|
8
|
+
* results and then *summarizes*. That ordering is the whole point: dropping the
|
|
9
|
+
* oldest span is cheap and works when every request is failing, whereas the
|
|
10
|
+
* summarizer must itself fit the window while holding the region it is condensing,
|
|
11
|
+
* so on a small local window it frequently fails for the same reason the original
|
|
12
|
+
* request did.
|
|
13
|
+
*
|
|
14
|
+
* Why `prepend` matters: `agent/request-error` is a Cordis **waterfall**, and
|
|
15
|
+
* `@deepseek-ai/dsh-compaction-basic` registers its summarization recovery on the
|
|
16
|
+
* same event. Listeners are stored in registration order and `{ prepend: true }`
|
|
17
|
+
* unshifts to the front, so this listener runs first regardless of which bundle
|
|
18
|
+
* mounted compaction (in a web profile compaction lives inside an agent-preset
|
|
19
|
+
* isolate realm, mounted later than any host-plane plugin). Returning
|
|
20
|
+
* `{ kind: 'retry' }` without calling `next()` vetoes the rest of the chain for
|
|
21
|
+
* that attempt; calling `next()` hands the problem to compaction.
|
|
22
|
+
*
|
|
23
|
+
* @module dsh-command-context-trim/auto-trim
|
|
24
|
+
*/
|
|
25
|
+
import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm';
|
|
26
|
+
import { logLine, describeError } from './render.js';
|
|
27
|
+
import { executeTrim } from './trim-session.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Register automatic context-overflow trimming.
|
|
31
|
+
* @param ctx - plugin context (token meter, LLM service, events, logger).
|
|
32
|
+
* @param config - resolved configuration.
|
|
33
|
+
* @returns nothing; listeners are owned by the plugin's fiber and disposed with it.
|
|
34
|
+
*/
|
|
35
|
+
export function registerAutoTrim(ctx, config) {
|
|
36
|
+
if (config.autoTrim !== true) return;
|
|
37
|
+
/** agent -> automatic trims already spent in the current overflow episode. */
|
|
38
|
+
const spent = new WeakMap();
|
|
39
|
+
/** session -> agent, so a successful assistant message can reset the budget. */
|
|
40
|
+
const actors = new WeakMap();
|
|
41
|
+
ctx.on(
|
|
42
|
+
'agent/request-error',
|
|
43
|
+
async ({ agent, failure, signal }, next) => {
|
|
44
|
+
if (failure?.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next();
|
|
45
|
+
const used = spent.get(agent) ?? 0;
|
|
46
|
+
if (used >= config.maxAutoTrimRetries) {
|
|
47
|
+
log(ctx, 'info', `context-overflow auto-trim: retry budget spent (${used}); leaving recovery to compaction`);
|
|
48
|
+
return next();
|
|
49
|
+
}
|
|
50
|
+
const generation = agent.session.surface.replaceGeneration;
|
|
51
|
+
let outcome;
|
|
52
|
+
try {
|
|
53
|
+
outcome = await executeTrim(ctx, config, { agent, signal, routedOnly: true });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
log(ctx, 'warn', `context-overflow auto-trim failed (${describeError(error)}); leaving recovery to compaction`);
|
|
56
|
+
return next();
|
|
57
|
+
}
|
|
58
|
+
if (signal.aborted) return next();
|
|
59
|
+
if (agent.session.surface.replaceGeneration <= generation) {
|
|
60
|
+
log(ctx, 'info', `context-overflow auto-trim: nothing safely trimmable; leaving recovery to compaction`);
|
|
61
|
+
return next();
|
|
62
|
+
}
|
|
63
|
+
spent.set(agent, used + 1);
|
|
64
|
+
actors.set(agent.session, agent);
|
|
65
|
+
log(ctx, 'info', `context-overflow auto-trim: ${logLine(outcome.plan, outcome.after, outcome.label)}`);
|
|
66
|
+
return { kind: 'retry' };
|
|
67
|
+
},
|
|
68
|
+
{ prepend: true }
|
|
69
|
+
);
|
|
70
|
+
// Mirror compaction-basic's accounting: one overflow episode's budget resets
|
|
71
|
+
// once the conversation advances or the agent goes idle.
|
|
72
|
+
ctx.on('agent/status', ({ agent, status }) => {
|
|
73
|
+
if (status === 'idle') spent.delete(agent);
|
|
74
|
+
});
|
|
75
|
+
ctx.on('session/event', (session, event) => {
|
|
76
|
+
if (event.type !== 'assistant/message') return;
|
|
77
|
+
const agent = actors.get(session);
|
|
78
|
+
if (agent !== undefined) spent.delete(agent);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Log through the context logger when it is available. */
|
|
83
|
+
function log(ctx, level, message) {
|
|
84
|
+
const logger = ctx.logger;
|
|
85
|
+
if (logger?.[level] === undefined) return;
|
|
86
|
+
logger[level](`context-trim: ${message}`);
|
|
87
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -15,7 +15,9 @@ export const DEFAULTS = Object.freeze({
|
|
|
15
15
|
minTailTokens: 2048,
|
|
16
16
|
protectHeadNodes: 1,
|
|
17
17
|
allowTailTrim: true,
|
|
18
|
-
markerSlackTokens: 64
|
|
18
|
+
markerSlackTokens: 64,
|
|
19
|
+
autoTrim: true,
|
|
20
|
+
maxAutoTrimRetries: 1
|
|
19
21
|
});
|
|
20
22
|
|
|
21
23
|
/** Every key this plugin accepts. */
|
|
@@ -27,7 +29,9 @@ const CONFIG_KEYS = new Set([
|
|
|
27
29
|
'minTailTokens',
|
|
28
30
|
'protectHeadNodes',
|
|
29
31
|
'allowTailTrim',
|
|
30
|
-
'markerSlackTokens'
|
|
32
|
+
'markerSlackTokens',
|
|
33
|
+
'autoTrim',
|
|
34
|
+
'maxAutoTrimRetries'
|
|
31
35
|
]);
|
|
32
36
|
|
|
33
37
|
/**
|
|
@@ -48,11 +52,15 @@ export function resolveConfig(config = {}) {
|
|
|
48
52
|
const protectHeadNodes = config.protectHeadNodes ?? DEFAULTS.protectHeadNodes;
|
|
49
53
|
const allowTailTrim = config.allowTailTrim ?? DEFAULTS.allowTailTrim;
|
|
50
54
|
const markerSlackTokens = config.markerSlackTokens ?? DEFAULTS.markerSlackTokens;
|
|
55
|
+
const autoTrim = config.autoTrim ?? DEFAULTS.autoTrim;
|
|
56
|
+
const maxAutoTrimRetries = config.maxAutoTrimRetries ?? DEFAULTS.maxAutoTrimRetries;
|
|
51
57
|
assertRatio('targetRatio', targetRatio);
|
|
52
58
|
assertNonNegativeInteger('reserveOutputTokens', reserveOutputTokens);
|
|
53
59
|
assertNonNegativeInteger('minTailTokens', minTailTokens);
|
|
54
60
|
assertNonNegativeInteger('protectHeadNodes', protectHeadNodes);
|
|
55
61
|
assertNonNegativeInteger('markerSlackTokens', markerSlackTokens);
|
|
62
|
+
assertNonNegativeInteger('maxAutoTrimRetries', maxAutoTrimRetries);
|
|
63
|
+
if (typeof autoTrim !== 'boolean') throw new Error('ContextTrimConfig: autoTrim must be a boolean');
|
|
56
64
|
if (typeof allowTailTrim !== 'boolean') throw new Error('ContextTrimConfig: allowTailTrim must be a boolean');
|
|
57
65
|
const retention = resolveRetention(config);
|
|
58
66
|
if (retention.retainRatio !== undefined && retention.retainRatio >= targetRatio) {
|
|
@@ -65,7 +73,9 @@ export function resolveConfig(config = {}) {
|
|
|
65
73
|
minTailTokens,
|
|
66
74
|
protectHeadNodes,
|
|
67
75
|
allowTailTrim,
|
|
68
|
-
markerSlackTokens
|
|
76
|
+
markerSlackTokens,
|
|
77
|
+
autoTrim,
|
|
78
|
+
maxAutoTrimRetries
|
|
69
79
|
});
|
|
70
80
|
}
|
|
71
81
|
|