dsh-command-context-trim 0.2.0 → 0.2.2
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 +59 -1
- package/README.md +53 -6
- package/README.zh.md +13 -3
- package/cordis.patch.yml +17 -1
- package/lib/auto-trim.js +30 -5
- package/lib/config.js +42 -3
- package/lib/prune-first.js +131 -0
- package/lib/render.js +18 -0
- package/lib/trim-session.js +45 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,62 @@ All notable changes to this project are documented here. This project adheres to
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.2.2] - 2026-09-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Cheap reduction first: oversized tool results are slimmed in place before any span is elided.** On the wall the
|
|
13
|
+
plugin now performs the same head/marker/tail transform DSH's own pruner performs — keeping the node, its tool call and
|
|
14
|
+
the prefix up to it — and only elides a whole span when that is not enough. It delegates to the official
|
|
15
|
+
`toolResultPruner` service when that service is reachable from the plugin's context (0.1.2; a 0.1.5 headless profile
|
|
16
|
+
where compaction stays on the host plane) and performs the identical transform itself when it is not (a 0.1.5 web
|
|
17
|
+
profile hides the pruner inside an agent-preset isolate realm). New configuration: `preferInPlacePrune` (default true)
|
|
18
|
+
and `pruneThresholdChars` / `pruneHeadChars` / `pruneTailChars` (8192 / 4096 / 1024, mirroring DSH's defaults).
|
|
19
|
+
- The in-place marker is `[... tool result middle trimmed to fit the context window ...]`, deliberately distinct from
|
|
20
|
+
DSH's `[... tool result middle pruned ...]`, so a session log shows which producer slimmed a node.
|
|
21
|
+
|
|
22
|
+
### Notes
|
|
23
|
+
|
|
24
|
+
- Verified end to end in the real `dsh-container` image (0.1.5-rc.2, isolated home, mock backend): a request of 23,429
|
|
25
|
+
tokens was refused, the plugin delegated to the official pruner, the retry came back at 19,995 tokens, the turn
|
|
26
|
+
completed, and the session log shows **no `compaction/start`** and **no span elided** — the pruner's `tool/result`
|
|
27
|
+
replacement kept `callId` and the step. The earlier span path was verified the same way (33,373 → 18,431 tokens).
|
|
28
|
+
The inline fallback (used only where the service is unreachable, i.e. a 0.1.5 web profile) is unit-tested against the
|
|
29
|
+
same semantics but not yet exercised end to end.
|
|
30
|
+
- `scripts/mock-overflow-server.mjs` gained `TOOL_COMMAND` / `TOOL_DESCRIPTION`: a bash tool call without a `description`
|
|
31
|
+
fails argument validation, which silently produced tiny error results instead of real tool output during testing.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
## [0.2.1] - 2026-09-22
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **A wall hit is no longer a one-shot.** Reported from a 32k backend while `settings.yaml` declared `contextWindow:
|
|
39
|
+
90000`: the first automatic trim targeted `(90000 - 8192) * 0.9 ~= 73.6k`, the retry was rejected again, and with
|
|
40
|
+
`maxAutoTrimRetries: 1` spent the recovery fell through to compaction — whose summarisation request is itself over the
|
|
41
|
+
real limit, so the turn died. The plugin now adapts instead of trusting a declaration it has just seen contradicted:
|
|
42
|
+
- the **first** attempt still trusts the declared window (cheap, and correct when the window is honest);
|
|
43
|
+
- every **later** attempt inside the same episode retargets to `failingRequestTokens * (1 - autoTrimShrink)`, i.e. it
|
|
44
|
+
halves the request that was just rejected — a geometric descent that converges however wrong the declared window is;
|
|
45
|
+
- a repeat attempt also logs an actionable warning: the routed model's `contextWindow` is larger than the backend
|
|
46
|
+
actually serves, fix it in `settings.yaml` (and the server's own context flag).
|
|
47
|
+
- Defaults changed accordingly: `maxAutoTrimRetries` **1 → 3**, and a new `autoTrimShrink` (**0.5**).
|
|
48
|
+
|
|
49
|
+
- **Automatic-trim decisions now log at `warn`.** An unattended rewrite of the user's context has to be visible in the
|
|
50
|
+
host log: a test run recorded the trim in the session log while the app log showed nothing, because `info` is filtered
|
|
51
|
+
at the default level.
|
|
52
|
+
- The README now explains how to tell this plugin's `compaction/prune` from DSH's tool-result pruner in a session log
|
|
53
|
+
(producer of the following event, and pre-step vs in-step position) and the two `usage` accounting traps.
|
|
54
|
+
|
|
55
|
+
### Notes
|
|
56
|
+
|
|
57
|
+
- The honest fix for a mismatched backend is still the setting itself: with `contextWindow: 32768` the very first trim
|
|
58
|
+
targets ~22k and succeeds without the adaptive path. The adaptive path exists so a wrong declaration degrades into
|
|
59
|
+
"more trimming than necessary" rather than a dead turn.
|
|
60
|
+
- Nothing else changed: the event, its gating (`CONTEXT_WINDOW_EXCEEDED` only), the elision preference tiers and the
|
|
61
|
+
newest-user-message barrier are untouched.
|
|
62
|
+
|
|
63
|
+
|
|
8
64
|
## [0.2.0] - 2026-09-16
|
|
9
65
|
|
|
10
66
|
### Added
|
|
@@ -101,7 +157,9 @@ All notable changes to this project are documented here. This project adheres to
|
|
|
101
157
|
content stays in the durable session log. v1 has no `/untrim`.
|
|
102
158
|
- Requires a harness that exposes `ctx.commands`, `ctx.tokenMeter`, and `ctx.llm` (DeepSeek Harness 0.1.2-rc.1 or later).
|
|
103
159
|
|
|
104
|
-
[Unreleased]: https://github.com/snailium/dsh-command-context-trim/compare/v0.2.
|
|
160
|
+
[Unreleased]: https://github.com/snailium/dsh-command-context-trim/compare/v0.2.2...HEAD
|
|
161
|
+
[0.2.2]: https://github.com/snailium/dsh-command-context-trim/compare/v0.2.1...v0.2.2
|
|
162
|
+
[0.2.1]: https://github.com/snailium/dsh-command-context-trim/compare/v0.2.0...v0.2.1
|
|
105
163
|
[0.2.0]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.1...v0.2.0
|
|
106
164
|
[0.1.1]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.0...v0.1.1
|
|
107
165
|
[0.1.0]: https://github.com/snailium/dsh-command-context-trim/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -110,9 +110,30 @@ Scope, deliberately narrow:
|
|
|
110
110
|
| ordinary threshold compaction (`agent/pre-step` pressure) | **never touched** |
|
|
111
111
|
| `/compact`, the tool-result pruner | **never touched** |
|
|
112
112
|
|
|
113
|
-
The per-episode retry budget (`maxAutoTrimRetries`, default
|
|
113
|
+
The per-episode retry budget (`maxAutoTrimRetries`, default 3) resets when a completed assistant message lands or the
|
|
114
114
|
agent goes idle, mirroring compaction's own overflow accounting. Set `autoTrim: false` to keep trimming manual.
|
|
115
115
|
|
|
116
|
+
**The cheap reduction runs first.** On the wall the plugin slims oversized tool results **in place** (head + marker + tail,
|
|
117
|
+
the same transform DSH's own pruner performs) and only elides a whole span when that is not enough:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
request refused → in-place tool-result slim → elide one span → compaction (prune + summarize)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
It calls the official `toolResultPruner` service when that service is reachable from the plugin's context (0.1.2, and a
|
|
124
|
+
0.1.5 headless profile where compaction stays on the host plane) and performs the same transform itself when it is not (a
|
|
125
|
+
0.1.5 web profile hides the pruner inside an agent-preset isolate realm). A slim keeps the node, its tool call and the
|
|
126
|
+
prefix up to it — the marker written in place says `[... tool result middle trimmed to fit the context window ...]`, which
|
|
127
|
+
is how a log shows that *this* plugin slimmed a node rather than DSH's pruner (`[... tool result middle pruned ...]`).
|
|
128
|
+
Set `preferInPlacePrune: false` to go straight to span elision.
|
|
129
|
+
|
|
130
|
+
**A wrong `contextWindow` degrades into extra trimming, not a dead turn.** The first attempt trusts the declared window.
|
|
131
|
+
If the retry is rejected again, the declaration has just been contradicted, so every later attempt in that episode
|
|
132
|
+
retargets to `failingRequestTokens * (1 - autoTrimShrink)` — by default *halving* the request that was rejected — which
|
|
133
|
+
converges however far the backend is below its declaration, and logs a warning naming the setting to fix. With an honest
|
|
134
|
+
window the first attempt succeeds and the adaptive path never runs. The real fix for a mismatched backend is the setting:
|
|
135
|
+
`contextWindow: 32768` makes the first trim target ~22k and fit.
|
|
136
|
+
|
|
116
137
|
Trade-off, stated plainly: an automatic trim **drops** the oldest span instead of summarizing it. On a small local
|
|
117
138
|
window that is the point — the summarizer must fit the region it is condensing and frequently cannot — but the dropped
|
|
118
139
|
text is replaced by a marker rather than a summary. `/compact` stays available, and the full text remains in the
|
|
@@ -171,14 +192,17 @@ Override on the `context-trim` row of a profile patch (the bundle's own `cordis.
|
|
|
171
192
|
| Key | Default | Meaning |
|
|
172
193
|
|---|---|---|
|
|
173
194
|
| `targetRatio` | `0.9` | `budget = floor((contextWindow - reserveOutputTokens) * targetRatio)` |
|
|
174
|
-
| `reserveOutputTokens` | `8192` | Output space kept for the model's own reply |
|
|
195
|
+
| `reserveOutputTokens` | `8192` | Output space kept for the model's own reply. Keep it plus the provider's own `maxTokens` inside the real window: a 32k server with `maxTokens: 16384` exhausts the window with prompt + output even when every prompt fits |
|
|
175
196
|
| `retainRatio` / `retainTokens` | `0.16` / — | Recent tail kept verbatim (mutually exclusive forms) |
|
|
176
197
|
| `minTailTokens` | `2048` | Absolute floor for that tail |
|
|
177
198
|
| `protectHeadNodes` | `1` | Leading nodes that are never trimmed |
|
|
178
199
|
| `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 |
|
|
179
200
|
| `markerSlackTokens` | `64` | Slack added to the priced marker so the post-trim request stays under budget |
|
|
180
201
|
| `autoTrim` | `true` | Trim automatically on `CONTEXT_WINDOW_EXCEEDED`; never fires on ordinary compaction |
|
|
181
|
-
| `maxAutoTrimRetries` | `
|
|
202
|
+
| `maxAutoTrimRetries` | `3` | Automatic trims allowed per overflow episode before compaction takes over |
|
|
203
|
+
| `autoTrimShrink` | `0.5` | After a repeat overflow, retarget to this fraction of the rejected request (geometric descent when the declared window is wrong) |
|
|
204
|
+
| `preferInPlacePrune` | `true` | Slim oversized tool results in place before planning any span; uses the official pruner when reachable |
|
|
205
|
+
| `pruneThresholdChars` / `pruneHeadChars` / `pruneTailChars` | `8192` / `4096` / `1024` | In-place slim budgets, mirroring DSH's own pruner defaults |
|
|
182
206
|
|
|
183
207
|
## Limits
|
|
184
208
|
|
|
@@ -192,6 +216,28 @@ Override on the `context-trim` row of a profile patch (the bundle's own `cordis.
|
|
|
192
216
|
- **Heuristic pricing.** Budgets use the token meter's own estimate — the same numbers `/compact` and the GUI context bar
|
|
193
217
|
use. Provider-reported usage drifts slightly from it.
|
|
194
218
|
|
|
219
|
+
## Reading the session log
|
|
220
|
+
|
|
221
|
+
`compaction/prune` has **two producers**, and only one of them is this plugin:
|
|
222
|
+
|
|
223
|
+
| Producer | Where | Followed by | Shape |
|
|
224
|
+
|---|---|---|---|
|
|
225
|
+
| **this plugin**, span elision | inside a step, right after a failed attempt | `user/message` whose `source.plugin` is `dsh-command-context-trim` | a whole span, both cut edges tool-pairing balanced |
|
|
226
|
+
| **this plugin**, in-place slim | same place, before any span is planned | `tool/result` replacing exactly one node | one `tool/result`, marker `[... tool result middle trimmed to fit the context window ...]`, `callId` kept |
|
|
227
|
+
| **DSH's tool-result pruner** | in compaction's own path | `tool/result` replacing exactly one node | a single `tool/result`, its `tool/call` kept |
|
|
228
|
+
|
|
229
|
+
Position is the other tell: a prune **between `step/end` and `step/start`** belongs to compaction's *pressure*
|
|
230
|
+
path (and this plugin, being overflow-only, is deliberately not involved); a prune **inside a step, after an
|
|
231
|
+
`assistant/attempt`** is an overflow recovery. A session with no `assistant/attempt` events never hit
|
|
232
|
+
`CONTEXT_WINDOW_EXCEEDED` at all, so this plugin never ran in it.
|
|
233
|
+
|
|
234
|
+
Two accounting traps when checking whether a trim helped: `assistant/message` `usage` is **per request**
|
|
235
|
+
(`input + cacheRead + output` for that call), not a session total — and a tool result is appended *after* the
|
|
236
|
+
request that produced its tool call, so comparing consecutive `usage.total` values measures "content was added",
|
|
237
|
+
not "the trim freed nothing". Compare the rejected attempt with the retry instead. Also, an in-place replacement of
|
|
238
|
+
the *last* node keeps the cached prefix (so `cacheRead` stays high); only a mid-conversation cut — this plugin, or
|
|
239
|
+
compaction rewriting the head — breaks it, which shows up as `cacheRead` dropping and `input` jumping on the next call.
|
|
240
|
+
|
|
195
241
|
## Development
|
|
196
242
|
|
|
197
243
|
### End-to-end overflow check (no model needed)
|
|
@@ -227,14 +273,15 @@ releases go out through `.github/workflows/publish.yml`, which is manual-only (`
|
|
|
227
273
|
|
|
228
274
|
| Check | State |
|
|
229
275
|
|---|---|
|
|
230
|
-
| `npm test` (
|
|
276
|
+
| `npm test` (72 tests: planner, args, surface apply + log replay, plugin handler, automatic overflow path, in-place slim, config) | ✅ passing |
|
|
231
277
|
| Isolated `DSH_HOME` install (`dsh plugin add file:…`) reconciling dependency **and** bundle layer | ✅ verified |
|
|
232
278
|
| Composed profile tree contains the `context-trim` insert row (`dsh --dump-config`) | ✅ verified |
|
|
233
279
|
| Profile boot with the plugin mounted (no load error) | ✅ reaches the credential check cleanly |
|
|
234
|
-
| Same suite against the pinned **published** harness packages (`npm ci`) | ✅
|
|
280
|
+
| Same suite against the pinned **published** harness packages (`npm ci`) | ✅ 72 passing |
|
|
235
281
|
| 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
282
|
| 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
|
|
283
|
+
| Same suite on the newer harness line (renamed replacement marker + surface system prompt); CI resolves it via the `next` tag, currently 0.1.5-rc.3 | ✅ 72 passing |
|
|
284
|
+
| **End-to-end in the real `dsh-container` image (0.1.5-rc.2, isolated home, mock backend)**: span path (33,373 → 18,431 tokens, `compaction/start` = 0) and in-place slim path (23,429 → 19,995 tokens, no span elided, pruner delegated to the official service) | ✅ both verified |
|
|
238
285
|
| CI workflow (Node 22 / 24) | ✅ green |
|
|
239
286
|
| npm release via GitHub Actions | ✅ 0.1.0 published with provenance (`+ dsh-command-context-trim@0.1.0`) |
|
|
240
287
|
| Isolated profile install **from the npm registry** (dependency + bundle layer + composed insert row) | ✅ 0.1.0 |
|
package/README.zh.md
CHANGED
|
@@ -72,7 +72,17 @@ agent-preset 的 isolate realm 里挂载的(web profile 里就是这样),
|
|
|
72
72
|
**范围刻意收窄**:只有 `CONTEXT_WINDOW_EXCEEDED` 才触发;其它请求错误、普通阈值 compaction(`agent/pre-step` 压力路径)、
|
|
73
73
|
`/compact`、工具结果 pruner **一律不碰**(有测试锁定注册的监听器集合)。
|
|
74
74
|
|
|
75
|
-
每轮溢出
|
|
75
|
+
每轮溢出 episode 的额度由 `maxAutoTrimRetries`(默认 3)限制,收到完成的 assistant 消息或 agent 空闲即重置。
|
|
76
|
+
|
|
77
|
+
**便宜的那一步先做**:撞墙时先把超长 tool result **原地瘦身**(head + 标记 + tail,与 DSH 自带 pruner 同一变换),不够才整段裁剪:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
请求被拒 → tool result 原地瘦身 → 整段裁剪一段 → compaction(prune + summarize)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
官方 `toolResultPruner` 服务在插件上下文里可见时(0.1.2、以及 compaction 仍在宿主层的 0.1.5 headless)直接委托它;不可见时(0.1.5 web profile 把 pruner 藏在 agent-preset isolate realm 里)自己做同样的变换。瘦身保留节点、tool call 与其之前的前缀;写入的标记是 `[... tool result middle trimmed to fit the context window ...]`,与 DSH 自己的 `[... tool result middle pruned ...]` 可区分。`preferInPlacePrune: false` 可跳过这一步。
|
|
84
|
+
|
|
85
|
+
**声明窗口与实际不符时,退化成"多裁一点"而不是"回合死掉"**:第一次仍按声明的 `contextWindow` 定目标;若重试又被拒(说明声明已被事实推翻),此后同一 episode 内改为按 `failingRequestTokens × (1 − autoTrimShrink)` 重定目标——默认**减半**刚被拒的那次请求,几何收敛,并打印告警指出该去改 `settings.yaml` 的哪个设置。窗口声明正确时第一次就成功,自适应路径根本不会触发。根治办法仍是配置本身:`contextWindow: 32768` 时首次裁剪目标约 22k,直接命中。
|
|
76
86
|
代价如实说:自动 trim 是**丢弃**最旧一段而不是摘要它——在小窗口下这正是要点,但被丢的内容只会变成一条占位标记。
|
|
77
87
|
`/compact` 仍在,原文也仍在会话日志里。
|
|
78
88
|
|
|
@@ -127,8 +137,8 @@ npm run link:harness # 也可改为从本地 dsh 安装的依赖闭包解析 @
|
|
|
127
137
|
|
|
128
138
|
已验证:34 个测试全部通过(选段算法、参数解析、真实 Session 上的 surface 改写与日志重放、插件命令注册与端到端裁剪,以及用**真实 `ctx.tokenMeter`** 验证「实测降幅 == 声明的 shadow price」和「新进程重放裁剪后日志得到完全一致的总量」);
|
|
129
139
|
隔离 `DSH_HOME` 安装后 dependency 与 bundle 层均正确 reconcile;`dsh --dump-config` 中出现 `context-trim` 行;profile 启动无加载错误。
|
|
130
|
-
CI 在 Node 22/24
|
|
131
|
-
npm 0.2.
|
|
140
|
+
CI 在 Node 22/24 × harness 0.1.2-rc.1/`next`(当前解析到 0.1.5-rc.3)四腿矩阵上跑同一套 61 项测试;发布通过 `.github/workflows/publish.yml`(手动 `workflow_dispatch`)。
|
|
141
|
+
npm 0.2.2(撞墙自动 trim:先原地瘦身、再整段裁剪、最后才摘要),并已在隔离 profile 里从 registry 安装验证;尚未执行:Web GUI 里的真实小窗口端到端验证(mock provider 与隔离实例已就绪)。
|
|
132
142
|
|
|
133
143
|
## License
|
|
134
144
|
|
package/cordis.patch.yml
CHANGED
|
@@ -32,4 +32,20 @@
|
|
|
32
32
|
# Ordinary threshold compaction is never touched.
|
|
33
33
|
autoTrim: true
|
|
34
34
|
# Automatic trims allowed per overflow episode before compaction takes over.
|
|
35
|
-
|
|
35
|
+
# More than one is needed when the declared contextWindow is larger than the
|
|
36
|
+
# backend really serves: see autoTrimShrink below.
|
|
37
|
+
maxAutoTrimRetries: 3
|
|
38
|
+
# After a repeat overflow the target becomes this fraction of the request that
|
|
39
|
+
# was just rejected (0.5 = halve it), which converges even when the declared
|
|
40
|
+
# window is wrong. The first attempt still trusts the declared window.
|
|
41
|
+
autoTrimShrink: 0.5
|
|
42
|
+
# Before eliding any span, slim oversized tool results IN PLACE (head +
|
|
43
|
+
# marker + tail), the same cheap reduction DSH's own pruner performs. Uses
|
|
44
|
+
# the official `toolResultPruner` service when it is reachable and performs
|
|
45
|
+
# the same transform itself when it is not (a 0.1.5 web profile hides it in
|
|
46
|
+
# an agent-preset isolate realm). A span is elided only when that is not
|
|
47
|
+
# enough.
|
|
48
|
+
preferInPlacePrune: true
|
|
49
|
+
pruneThresholdChars: 8192
|
|
50
|
+
pruneHeadChars: 4096
|
|
51
|
+
pruneTailChars: 1024
|
package/lib/auto-trim.js
CHANGED
|
@@ -20,10 +20,18 @@
|
|
|
20
20
|
* `{ kind: 'retry' }` without calling `next()` vetoes the rest of the chain for
|
|
21
21
|
* that attempt; calling `next()` hands the problem to compaction.
|
|
22
22
|
*
|
|
23
|
+
* Before any span is elided, oversized tool results are slimmed **in place** (the
|
|
24
|
+
* cheap, information-preserving reduction DSH itself uses); a span is only elided
|
|
25
|
+
* when that is not enough. See `./prune-first.js`.
|
|
26
|
+
*
|
|
27
|
+
* Every decision here logs at `warn`: an unattended rewrite of the user's context
|
|
28
|
+
* must be visible in the host log, not filtered behind an info level (an earlier
|
|
29
|
+
* run recorded a trim in the session log while the app log showed nothing).
|
|
30
|
+
*
|
|
23
31
|
* @module dsh-command-context-trim/auto-trim
|
|
24
32
|
*/
|
|
25
33
|
import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm';
|
|
26
|
-
import {
|
|
34
|
+
import { logLineFor, describeError } from './render.js';
|
|
27
35
|
import { executeTrim } from './trim-session.js';
|
|
28
36
|
|
|
29
37
|
/**
|
|
@@ -44,25 +52,42 @@ export function registerAutoTrim(ctx, config) {
|
|
|
44
52
|
if (failure?.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next();
|
|
45
53
|
const used = spent.get(agent) ?? 0;
|
|
46
54
|
if (used >= config.maxAutoTrimRetries) {
|
|
47
|
-
log(ctx, '
|
|
55
|
+
log(ctx, 'warn', `context-overflow auto-trim: retry budget spent (${used}); leaving recovery to compaction`);
|
|
48
56
|
return next();
|
|
49
57
|
}
|
|
58
|
+
// First attempt trusts the routed model's declared window. Every later
|
|
59
|
+
// attempt has proof that the declared window is wrong — this exact request
|
|
60
|
+
// was rejected — so the target becomes a fraction of the size that just
|
|
61
|
+
// failed, which converges even when the backend is far smaller than
|
|
62
|
+
// settings.yaml claims.
|
|
63
|
+
const failingTotal = ctx.tokenMeter.measure(agent.session).totalTokens;
|
|
64
|
+
const budgetCeiling =
|
|
65
|
+
used === 0 ? undefined : Math.max(1, Math.floor(failingTotal * (1 - config.autoTrimShrink)));
|
|
66
|
+
if (budgetCeiling !== undefined) {
|
|
67
|
+
log(
|
|
68
|
+
ctx,
|
|
69
|
+
'warn',
|
|
70
|
+
`context-overflow auto-trim: attempt ${used + 1} — a request of ~${failingTotal} tokens was still rejected; ` +
|
|
71
|
+
`retargeting to ~${budgetCeiling} tokens. If this repeats, the routed model's contextWindow is larger than the ` +
|
|
72
|
+
`backend actually serves — fix it in settings.yaml (and in the server's own context flag).`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
50
75
|
const generation = agent.session.surface.replaceGeneration;
|
|
51
76
|
let outcome;
|
|
52
77
|
try {
|
|
53
|
-
outcome = await executeTrim(ctx, config, { agent, signal, routedOnly: true });
|
|
78
|
+
outcome = await executeTrim(ctx, config, { agent, signal, routedOnly: true, budgetCeiling, inPlaceFirst: true });
|
|
54
79
|
} catch (error) {
|
|
55
80
|
log(ctx, 'warn', `context-overflow auto-trim failed (${describeError(error)}); leaving recovery to compaction`);
|
|
56
81
|
return next();
|
|
57
82
|
}
|
|
58
83
|
if (signal.aborted) return next();
|
|
59
84
|
if (agent.session.surface.replaceGeneration <= generation) {
|
|
60
|
-
log(ctx, '
|
|
85
|
+
log(ctx, 'warn', `context-overflow auto-trim: nothing safely trimmable; leaving recovery to compaction`);
|
|
61
86
|
return next();
|
|
62
87
|
}
|
|
63
88
|
spent.set(agent, used + 1);
|
|
64
89
|
actors.set(agent.session, agent);
|
|
65
|
-
log(ctx, '
|
|
90
|
+
log(ctx, 'warn', `context-overflow auto-trim: ${logLineFor(outcome)}`);
|
|
66
91
|
return { kind: 'retry' };
|
|
67
92
|
},
|
|
68
93
|
{ prepend: true }
|
package/lib/config.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* @module dsh-command-context-trim/config
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { TRIM_MARKER } from './prune-first.js';
|
|
11
|
+
|
|
10
12
|
/** Built-in defaults, mirrored by the bundle patch's commented config block. */
|
|
11
13
|
export const DEFAULTS = Object.freeze({
|
|
12
14
|
targetRatio: 0.9,
|
|
@@ -17,7 +19,12 @@ export const DEFAULTS = Object.freeze({
|
|
|
17
19
|
allowTailTrim: true,
|
|
18
20
|
markerSlackTokens: 64,
|
|
19
21
|
autoTrim: true,
|
|
20
|
-
maxAutoTrimRetries:
|
|
22
|
+
maxAutoTrimRetries: 3,
|
|
23
|
+
autoTrimShrink: 0.5,
|
|
24
|
+
preferInPlacePrune: true,
|
|
25
|
+
pruneThresholdChars: 8192,
|
|
26
|
+
pruneHeadChars: 4096,
|
|
27
|
+
pruneTailChars: 1024
|
|
21
28
|
});
|
|
22
29
|
|
|
23
30
|
/** Every key this plugin accepts. */
|
|
@@ -31,7 +38,12 @@ const CONFIG_KEYS = new Set([
|
|
|
31
38
|
'allowTailTrim',
|
|
32
39
|
'markerSlackTokens',
|
|
33
40
|
'autoTrim',
|
|
34
|
-
'maxAutoTrimRetries'
|
|
41
|
+
'maxAutoTrimRetries',
|
|
42
|
+
'autoTrimShrink',
|
|
43
|
+
'preferInPlacePrune',
|
|
44
|
+
'pruneThresholdChars',
|
|
45
|
+
'pruneHeadChars',
|
|
46
|
+
'pruneTailChars'
|
|
35
47
|
]);
|
|
36
48
|
|
|
37
49
|
/**
|
|
@@ -54,13 +66,29 @@ export function resolveConfig(config = {}) {
|
|
|
54
66
|
const markerSlackTokens = config.markerSlackTokens ?? DEFAULTS.markerSlackTokens;
|
|
55
67
|
const autoTrim = config.autoTrim ?? DEFAULTS.autoTrim;
|
|
56
68
|
const maxAutoTrimRetries = config.maxAutoTrimRetries ?? DEFAULTS.maxAutoTrimRetries;
|
|
69
|
+
const autoTrimShrink = config.autoTrimShrink ?? DEFAULTS.autoTrimShrink;
|
|
70
|
+
const preferInPlacePrune = config.preferInPlacePrune ?? DEFAULTS.preferInPlacePrune;
|
|
71
|
+
const pruneThresholdChars = config.pruneThresholdChars ?? DEFAULTS.pruneThresholdChars;
|
|
72
|
+
const pruneHeadChars = config.pruneHeadChars ?? DEFAULTS.pruneHeadChars;
|
|
73
|
+
const pruneTailChars = config.pruneTailChars ?? DEFAULTS.pruneTailChars;
|
|
57
74
|
assertRatio('targetRatio', targetRatio);
|
|
58
75
|
assertNonNegativeInteger('reserveOutputTokens', reserveOutputTokens);
|
|
59
76
|
assertNonNegativeInteger('minTailTokens', minTailTokens);
|
|
60
77
|
assertNonNegativeInteger('protectHeadNodes', protectHeadNodes);
|
|
61
78
|
assertNonNegativeInteger('markerSlackTokens', markerSlackTokens);
|
|
62
79
|
assertNonNegativeInteger('maxAutoTrimRetries', maxAutoTrimRetries);
|
|
80
|
+
assertRatio('autoTrimShrink', autoTrimShrink);
|
|
63
81
|
if (typeof autoTrim !== 'boolean') throw new Error('ContextTrimConfig: autoTrim must be a boolean');
|
|
82
|
+
if (typeof preferInPlacePrune !== 'boolean') throw new Error('ContextTrimConfig: preferInPlacePrune must be a boolean');
|
|
83
|
+
assertNonNegativeInteger('pruneHeadChars', pruneHeadChars);
|
|
84
|
+
assertNonNegativeInteger('pruneTailChars', pruneTailChars);
|
|
85
|
+
assertPositiveInteger('pruneThresholdChars', pruneThresholdChars);
|
|
86
|
+
// Mirror the official pruner's load-time guard: the emitted head + marker + tail
|
|
87
|
+
// must fit inside the threshold, or a rewrite could not both shrink and comply.
|
|
88
|
+
const emitted = pruneHeadChars + TRIM_MARKER.length + pruneTailChars;
|
|
89
|
+
if (emitted > pruneThresholdChars) {
|
|
90
|
+
throw new Error(`ContextTrimConfig: pruneHeadChars + marker + pruneTailChars (${emitted}) must be at most pruneThresholdChars (${pruneThresholdChars})`);
|
|
91
|
+
}
|
|
64
92
|
if (typeof allowTailTrim !== 'boolean') throw new Error('ContextTrimConfig: allowTailTrim must be a boolean');
|
|
65
93
|
const retention = resolveRetention(config);
|
|
66
94
|
if (retention.retainRatio !== undefined && retention.retainRatio >= targetRatio) {
|
|
@@ -75,7 +103,12 @@ export function resolveConfig(config = {}) {
|
|
|
75
103
|
allowTailTrim,
|
|
76
104
|
markerSlackTokens,
|
|
77
105
|
autoTrim,
|
|
78
|
-
maxAutoTrimRetries
|
|
106
|
+
maxAutoTrimRetries,
|
|
107
|
+
autoTrimShrink,
|
|
108
|
+
preferInPlacePrune,
|
|
109
|
+
pruneThresholdChars,
|
|
110
|
+
pruneHeadChars,
|
|
111
|
+
pruneTailChars
|
|
79
112
|
});
|
|
80
113
|
}
|
|
81
114
|
|
|
@@ -130,6 +163,12 @@ function assertRatio(name, value) {
|
|
|
130
163
|
}
|
|
131
164
|
}
|
|
132
165
|
|
|
166
|
+
function assertPositiveInteger(name, value) {
|
|
167
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
168
|
+
throw new Error(`ContextTrimConfig: ${name} (${String(value)}) must be a positive integer`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
133
172
|
function assertNonNegativeInteger(name, value) {
|
|
134
173
|
if (!Number.isInteger(value) || value < 0) {
|
|
135
174
|
throw new Error(`ContextTrimConfig: ${name} (${String(value)}) must be a non-negative integer`);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-place tool-result slimming, run **before** any span elision.
|
|
3
|
+
*
|
|
4
|
+
* DSH already owns this idea (`@deepseek-ai/dsh-compaction-tool-result-pruner`),
|
|
5
|
+
* but a host-plane plugin can only reach that service where compaction itself is
|
|
6
|
+
* mounted on the host plane. In a 0.1.5 web profile the host row is disabled and
|
|
7
|
+
* the pruner is re-mounted inside an agent-preset isolate realm, where
|
|
8
|
+
* `ctx.get('toolResultPruner')` resolves nothing. So this module prefers the
|
|
9
|
+
* official service when it is visible and otherwise performs the same transform
|
|
10
|
+
* itself, which keeps the cheap reduction ahead of the expensive one:
|
|
11
|
+
*
|
|
12
|
+
* slim oversized tool results in place → elide a whole span → compaction
|
|
13
|
+
*
|
|
14
|
+
* The transform mirrors the official one: text is measured and sliced in Unicode
|
|
15
|
+
* code points (never splitting a surrogate pair), the head and tail are kept, one
|
|
16
|
+
* marker replaces the removed middle, non-text blocks keep their order, and the
|
|
17
|
+
* replacement carries the complete original event data except `content` — so the
|
|
18
|
+
* tool call keeps its result and the step stays intact.
|
|
19
|
+
*
|
|
20
|
+
* A `tool/result` replacement is only legal inside an open turn, which is exactly
|
|
21
|
+
* the state the automatic overflow path runs in; the idle `/trim` command never
|
|
22
|
+
* uses this module.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-command-context-trim/prune-first
|
|
25
|
+
*/
|
|
26
|
+
import { freezeMessage } from '@deepseek-ai/dsh-llm';
|
|
27
|
+
import { replaceKeys, replacementOp } from './session-compat.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Marker replacing the removed middle. Deliberately worded for the model and
|
|
31
|
+
* distinct from DSH's own `[... tool result middle pruned ...]`, so a session log
|
|
32
|
+
* shows which producer slimmed a node.
|
|
33
|
+
*/
|
|
34
|
+
export const TRIM_MARKER = '\n\n[... tool result middle trimmed to fit the context window ...]\n\n';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Slim every over-budget tool result on the current surface.
|
|
38
|
+
* @param ctx - plugin context; `ctx.get('toolResultPruner')` is used when present.
|
|
39
|
+
* @param session - session whose surface is rewritten.
|
|
40
|
+
* @param config - resolved configuration.
|
|
41
|
+
* @returns `{ pruned, via, replacementSeq }`; `pruned` is the number of nodes rewritten.
|
|
42
|
+
*/
|
|
43
|
+
export function shrinkOversizedToolResults(ctx, session, config) {
|
|
44
|
+
if (config.preferInPlacePrune !== true) return { pruned: 0, via: 'disabled' };
|
|
45
|
+
const official = ctx.get?.('toolResultPruner');
|
|
46
|
+
if (official !== undefined && typeof official.pruneSession === 'function') {
|
|
47
|
+
const outcome = official.pruneSession(session);
|
|
48
|
+
const pruned = Array.isArray(outcome?.pruned) ? outcome.pruned.length : 0;
|
|
49
|
+
return { pruned, via: 'service', replacementSeq: outcome?.pruned?.at(-1)?.replacementSeq };
|
|
50
|
+
}
|
|
51
|
+
const keys = replaceKeys();
|
|
52
|
+
let pruned = 0;
|
|
53
|
+
let replacementSeq;
|
|
54
|
+
// Snapshot first: replacements are appended while iterating.
|
|
55
|
+
for (const seq of [...session.surface.nodes]) {
|
|
56
|
+
const event = session.eventAt(seq);
|
|
57
|
+
if (event?.type !== 'tool/result') continue;
|
|
58
|
+
const result = event.data.message.content[0];
|
|
59
|
+
const content = pruneContent(result.content, config);
|
|
60
|
+
if (content === null) continue;
|
|
61
|
+
const message = freezeMessage({
|
|
62
|
+
...event.data.message,
|
|
63
|
+
content: [{ ...result, content }]
|
|
64
|
+
});
|
|
65
|
+
session.append('compaction/prune', {
|
|
66
|
+
shadowedRange: { start: seq, end: seq },
|
|
67
|
+
shadowedSeqs: [seq],
|
|
68
|
+
shadowedTokenCount: ctx.tokenMeter.estimateMessage(event.data.message)
|
|
69
|
+
});
|
|
70
|
+
const replacement = session.append('tool/result', { ...event.data, message }, {
|
|
71
|
+
surfaceOp: replacementOp(keys, seq, seq),
|
|
72
|
+
sourceEventSeqs: [seq]
|
|
73
|
+
});
|
|
74
|
+
pruned += 1;
|
|
75
|
+
replacementSeq = replacement.seq;
|
|
76
|
+
}
|
|
77
|
+
return { pruned, via: 'inline', ...(replacementSeq === undefined ? {} : { replacementSeq }) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Measure tool-result text in Unicode code points; non-text blocks cost zero.
|
|
82
|
+
* @param blocks - tool-result content blocks.
|
|
83
|
+
* @returns total code points across text blocks.
|
|
84
|
+
*/
|
|
85
|
+
export function measureContent(blocks) {
|
|
86
|
+
let chars = 0;
|
|
87
|
+
for (const block of blocks) if (block.type === 'text') chars += codePointLength(block.text);
|
|
88
|
+
return chars;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Replace an over-budget text middle while retaining rich-block order.
|
|
93
|
+
* @param blocks - original tool-result content.
|
|
94
|
+
* @param config - resolved configuration with the character budgets.
|
|
95
|
+
* @returns rewritten content, or `null` when the text is already within budget.
|
|
96
|
+
*/
|
|
97
|
+
export function pruneContent(blocks, config) {
|
|
98
|
+
const totalChars = measureContent(blocks);
|
|
99
|
+
if (totalChars <= config.pruneThresholdChars) return null;
|
|
100
|
+
const removedStart = config.pruneHeadChars;
|
|
101
|
+
const removedEnd = totalChars - config.pruneTailChars;
|
|
102
|
+
const pruned = [];
|
|
103
|
+
let consumed = 0;
|
|
104
|
+
let markerInserted = false;
|
|
105
|
+
for (const block of blocks) {
|
|
106
|
+
if (block.type !== 'text') {
|
|
107
|
+
pruned.push(block);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const points = Array.from(block.text);
|
|
111
|
+
const blockStart = consumed;
|
|
112
|
+
const blockEnd = blockStart + points.length;
|
|
113
|
+
const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart));
|
|
114
|
+
const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart));
|
|
115
|
+
const marker = blockStart < removedEnd && blockEnd > removedStart && !markerInserted ? TRIM_MARKER : '';
|
|
116
|
+
if (marker.length > 0) markerInserted = true;
|
|
117
|
+
const text = points.slice(0, headEnd).join('') + marker + points.slice(tailStart).join('');
|
|
118
|
+
if (text.length > 0) pruned.push({ ...block, text });
|
|
119
|
+
consumed = blockEnd;
|
|
120
|
+
}
|
|
121
|
+
if (!markerInserted) return null;
|
|
122
|
+
const charsAfter = measureContent(pruned);
|
|
123
|
+
// Refuse a rewrite that would not actually be smaller and within budget.
|
|
124
|
+
if (charsAfter > config.pruneThresholdChars || charsAfter >= totalChars) return null;
|
|
125
|
+
return pruned;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Count Unicode code points without splitting surrogate pairs. */
|
|
129
|
+
function codePointLength(text) {
|
|
130
|
+
return Array.from(text).length;
|
|
131
|
+
}
|
package/lib/render.js
CHANGED
|
@@ -113,3 +113,21 @@ export function describeError(error) {
|
|
|
113
113
|
return '<unrenderable thrown value>';
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Render one automatic-path outcome, which may be an in-place slim with no span
|
|
119
|
+
* elision, a span elision, or a slim followed by a span elision.
|
|
120
|
+
* @param outcome - the object returned by `executeTrim`.
|
|
121
|
+
* @returns a single-line account.
|
|
122
|
+
*/
|
|
123
|
+
export function logLineFor(outcome) {
|
|
124
|
+
if (outcome.plan === undefined) {
|
|
125
|
+
return (
|
|
126
|
+
`slimmed ${outcome.pruned.pruned} oversized tool result(s) in place for ${outcome.label}; ` +
|
|
127
|
+
`request ~${outcome.before.totalTokens} → ~${outcome.after.totalTokens} tokens`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const base = logLine(outcome.plan, outcome.after, outcome.label);
|
|
131
|
+
if (outcome.pruned === undefined || outcome.pruned.pruned === 0) return base;
|
|
132
|
+
return `${outcome.pruned.pruned} tool result(s) slimmed in place, then ${base}`;
|
|
133
|
+
}
|
package/lib/trim-session.js
CHANGED
|
@@ -14,6 +14,7 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a
|
|
|
14
14
|
import { applyTrim, createMarkerMessage, provisionalMarker } from './apply.js';
|
|
15
15
|
import { budgetFor, retentionFor } from './config.js';
|
|
16
16
|
import { planTrim } from './plan.js';
|
|
17
|
+
import { shrinkOversizedToolResults } from './prune-first.js';
|
|
17
18
|
import { describeNonSpan, preview, report } from './render.js';
|
|
18
19
|
import { replaceKeys } from './session-compat.js';
|
|
19
20
|
import { resolveTarget, targetHeader } from './target.js';
|
|
@@ -26,6 +27,10 @@ import { resolveTarget, targetHeader } from './target.js';
|
|
|
26
27
|
* @property {number} [explicitBudget] - explicit token budget instead of a resolved window.
|
|
27
28
|
* @property {boolean} [check] - plan only, mutate nothing.
|
|
28
29
|
* @property {boolean} [routedOnly] - target the route the last durable request used.
|
|
30
|
+
* @property {number} [budgetCeiling] - hard upper bound on the target budget, used when a
|
|
31
|
+
* request of a known size has just been rejected and the declared window cannot be trusted.
|
|
32
|
+
* @property {boolean} [inPlaceFirst] - slim oversized tool results in place before planning a
|
|
33
|
+
* span. Only legal inside an open turn, so the automatic path sets it and `/trim` never does.
|
|
29
34
|
*/
|
|
30
35
|
|
|
31
36
|
/**
|
|
@@ -38,16 +43,43 @@ import { resolveTarget, targetHeader } from './target.js';
|
|
|
38
43
|
* surface was actually rewritten.
|
|
39
44
|
*/
|
|
40
45
|
export async function executeTrim(ctx, config, request) {
|
|
41
|
-
const { agent, signal, requestedRoute, explicitBudget, check = false, routedOnly = false } = request;
|
|
46
|
+
const { agent, signal, requestedRoute, explicitBudget, check = false, routedOnly = false, budgetCeiling, inPlaceFirst = false } = request;
|
|
42
47
|
signal?.throwIfAborted?.();
|
|
43
48
|
const session = agent.session;
|
|
44
49
|
assertNoOpenCompaction(session);
|
|
45
50
|
const target =
|
|
46
51
|
explicitBudget === undefined ? await resolveTarget(ctx, agent, requestedRoute, signal, { routedOnly }) : undefined;
|
|
47
|
-
const
|
|
48
|
-
const
|
|
52
|
+
const windowBudget = explicitBudget ?? budgetFor(target.contextWindow, config);
|
|
53
|
+
const budget = budgetCeiling === undefined ? windowBudget : Math.min(windowBudget, budgetCeiling);
|
|
54
|
+
const baseLabel = target === undefined ? `an explicit ${budget}-token budget` : `${target.label} (window ${target.contextWindow})`;
|
|
55
|
+
const label = budget === windowBudget ? baseLabel : `${baseLabel}, capped at ${budget}`;
|
|
49
56
|
const header = target === undefined ? undefined : targetHeader(session, target);
|
|
50
|
-
|
|
57
|
+
let measurement = ctx.tokenMeter.measure(session, header);
|
|
58
|
+
// Cheap reduction first: a single oversized tool result is slimmed in place
|
|
59
|
+
// (keeping the node, its tool call and the prefix up to it) and a span is only
|
|
60
|
+
// elided when that is not enough.
|
|
61
|
+
let pruned;
|
|
62
|
+
if (inPlaceFirst) {
|
|
63
|
+
const shrunk = shrinkOversizedToolResults(ctx, session, config);
|
|
64
|
+
if (shrunk.pruned > 0) {
|
|
65
|
+
const afterPrune = ctx.tokenMeter.measure(session, header);
|
|
66
|
+
pruned = shrunk;
|
|
67
|
+
if (afterPrune.totalTokens <= budget) {
|
|
68
|
+
return {
|
|
69
|
+
result: {
|
|
70
|
+
kind: 'success',
|
|
71
|
+
text: `Slimmed ${shrunk.pruned} oversized tool result(s) in place for ${label}: ~${measurement.totalTokens} → ~${afterPrune.totalTokens} tokens (target ${budget}).`,
|
|
72
|
+
...(shrunk.replacementSeq === undefined ? {} : { sourceEventSeq: shrunk.replacementSeq })
|
|
73
|
+
},
|
|
74
|
+
label,
|
|
75
|
+
before: measurement,
|
|
76
|
+
after: afterPrune,
|
|
77
|
+
pruned: shrunk
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
measurement = afterPrune;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
51
83
|
const nodes = measurement.nodes.map((node) => {
|
|
52
84
|
const type = session.eventAt(node.seq)?.type;
|
|
53
85
|
return {
|
|
@@ -65,6 +97,10 @@ export async function executeTrim(ctx, config, request) {
|
|
|
65
97
|
if (nodes.length === 0) {
|
|
66
98
|
return { result: { kind: 'success', text: `Nothing to trim: ${session.id} has no model-visible messages yet.` } };
|
|
67
99
|
}
|
|
100
|
+
const notePrune = (result) =>
|
|
101
|
+
pruned === undefined
|
|
102
|
+
? result
|
|
103
|
+
: { ...result, text: `${result.text}\n(Note: ${pruned.pruned} oversized tool result(s) were already slimmed in place.)` };
|
|
68
104
|
// The retained tail scales with the capacity being fitted: the target window
|
|
69
105
|
// when one is known, otherwise the explicit budget itself.
|
|
70
106
|
const retainTokens = retentionFor(target?.contextWindow ?? budget, config);
|
|
@@ -84,18 +120,18 @@ export async function executeTrim(ctx, config, request) {
|
|
|
84
120
|
});
|
|
85
121
|
let markerTokens = ctx.tokenMeter.estimateMessage(provisionalMarker());
|
|
86
122
|
let plan = planFor(markerTokens);
|
|
87
|
-
if (plan.kind !== 'span') return { result: describeNonSpan(plan, label), plan, label, before: measurement };
|
|
123
|
+
if (plan.kind !== 'span') return { result: notePrune(describeNonSpan(plan, label)), plan, label, before: measurement, ...(pruned === undefined ? {} : { pruned }) };
|
|
88
124
|
let marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
|
|
89
125
|
const finalMarkerTokens = ctx.tokenMeter.estimateMessage(marker);
|
|
90
126
|
if (finalMarkerTokens > markerTokens) {
|
|
91
127
|
// The marker now carries real numbers; re-plan once so its own price is exact.
|
|
92
128
|
plan = planFor(finalMarkerTokens);
|
|
93
|
-
if (plan.kind !== 'span') return { result: describeNonSpan(plan, label), plan, label, before: measurement };
|
|
129
|
+
if (plan.kind !== 'span') return { result: notePrune(describeNonSpan(plan, label)), plan, label, before: measurement, ...(pruned === undefined ? {} : { pruned }) };
|
|
94
130
|
marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
|
|
95
131
|
markerTokens = finalMarkerTokens;
|
|
96
132
|
}
|
|
97
133
|
if (check) {
|
|
98
|
-
return { result: { kind: 'success', text: preview(plan, label, markerTokens) }, plan, label, before: measurement };
|
|
134
|
+
return { result: { kind: 'success', text: preview(plan, label, markerTokens) }, plan, label, before: measurement, ...(pruned === undefined ? {} : { pruned }) };
|
|
99
135
|
}
|
|
100
136
|
const replacement = applyTrim(session, plan, marker, replaceKeys());
|
|
101
137
|
const after = ctx.tokenMeter.measure(session, header);
|
|
@@ -106,7 +142,8 @@ export async function executeTrim(ctx, config, request) {
|
|
|
106
142
|
before: measurement,
|
|
107
143
|
after,
|
|
108
144
|
markerTokens,
|
|
109
|
-
replacement
|
|
145
|
+
replacement,
|
|
146
|
+
...(pruned === undefined ? {} : { pruned })
|
|
110
147
|
};
|
|
111
148
|
}
|
|
112
149
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-command-context-trim",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Model-free /trim for DeepSeek Harness — drop the oldest, least valuable span of context on demand (the `/trim` command) or automatically when a request hits the model's context wall, without any model call.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "snailium",
|