dsh-command-context-trim 0.1.1 → 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 CHANGED
@@ -5,6 +5,102 @@ 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
+
64
+ ## [0.2.0] - 2026-09-16
65
+
66
+ ### Added
67
+
68
+ - **Automatic trimming on the context wall.** A `prepend`ed `agent/request-error` listener reacts to
69
+ `CONTEXT_WINDOW_EXCEEDED`, frees space with no model call, and asks the loop to retry. Only when it cannot free
70
+ anything does the waterfall continue into DSH's own recovery (prune + summarize) — so a session that hits the wall
71
+ is repaired by *dropping* the oldest span first and only pays for summarization when dropping cannot help.
72
+ This is the unattended form of `/trim`; it is the same execution, invoked by the harness instead of a human.
73
+ - Configuration `autoTrim` (default `true`) and `maxAutoTrimRetries` (default `1`, per overflow episode).
74
+ - **The elided span is chosen by explicit preference tiers.** Elision always starts at the oldest balanced cut, and the
75
+ search is graded: (1) stay outside the retained tail and keep the final message, with the configured retention relaxed
76
+ step by step only if the fit otherwise fails; (2) reach into the retained tail, still keeping the final message;
77
+ (3) last resort — include the final message, typically the current step's assistant tool-call plus its tool result,
78
+ which can only be removed as a pair. `allowTailTrim: false` ends the list after tier 1. The plan and every rendered
79
+ result state when the last-resort tier was used.
80
+ - **Planner anchor changed: the newest `user/message` is protected, the final node is not.** The previous rule
81
+ ("never elide the final surface node") deadlocked the most common overflow shape — one large assistant tool-call whose
82
+ tool result is the last node could not be removed as a pair, so only a handful of tokens were freeable while the
83
+ request stayed over the wall (observed live: "largest balanced span frees ~4 of the ~4631 tokens needed"). The newest
84
+ human instruction is now a **barrier** (never elided, never crossed) and everything after it stays eligible, tool
85
+ pairing still enforced on both cut edges.
86
+
87
+ ### Notes
88
+
89
+ - **Scope: the context wall only.** The listener fires exclusively for `CONTEXT_WINDOW_EXCEEDED`. Ordinary
90
+ threshold compaction (`agent/pre-step` pressure), `/compact`, and the tool-result pruner are untouched — this is
91
+ asserted by a test that pins the registered listener set.
92
+ - Why `prepend` is required: `agent/request-error` is a Cordis waterfall and compaction registers its summarization
93
+ recovery on the same event. Cordis stores listeners in registration order and `{ prepend: true }` unshifts, so this
94
+ listener runs first even when compaction is mounted later inside an agent-preset isolate realm (as it is in a web
95
+ profile). Returning `{ kind: 'retry' }` without calling `next()` vetoes summarization for that attempt.
96
+ - The per-episode budget resets when a completed assistant message lands or the agent goes idle, mirroring
97
+ compaction's own overflow accounting.
98
+ - Trade-off, stated plainly: an automatic trim **drops** the oldest span rather than summarizing it. That is the
99
+ point on a small local window — the summarizer must fit the region it is condensing and frequently cannot — but it
100
+ does mean the dropped text is replaced by a marker instead of a summary. `/compact` remains available, and the full
101
+ text stays in the durable session log.
102
+
103
+
8
104
  ## [0.1.1] - 2026-09-15
9
105
 
10
106
  ### Fixed
@@ -61,6 +157,9 @@ All notable changes to this project are documented here. This project adheres to
61
157
  content stays in the durable session log. v1 has no `/untrim`.
62
158
  - Requires a harness that exposes `ctx.commands`, `ctx.tokenMeter`, and `ctx.llm` (DeepSeek Harness 0.1.2-rc.1 or later).
63
159
 
64
- [Unreleased]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.1...HEAD
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
163
+ [0.2.0]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.1...v0.2.0
65
164
  [0.1.1]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.0...v0.1.1
66
165
  [0.1.0]: https://github.com/snailium/dsh-command-context-trim/releases/tag/v0.1.0
package/README.md CHANGED
@@ -84,17 +84,87 @@ Design consequences:
84
84
  is idle, so it cannot interleave with a turn, `/compact`, or automatic compaction; it also refuses while an unmatched
85
85
  `compaction/start` is open.
86
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 3) 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
+ **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
+
137
+ Trade-off, stated plainly: an automatic trim **drops** the oldest span instead of summarizing it. On a small local
138
+ window that is the point — the summarizer must fit the region it is condensing and frequently cannot — but the dropped
139
+ text is replaced by a marker rather than a summary. `/compact` stays available, and the full text remains in the
140
+ durable session log.
141
+
87
142
  ## What is protected
88
143
 
89
144
  | Protected | Why |
90
145
  |---|---|
91
146
  | Leading `protectHeadNodes` nodes (default 1) | The task statement — dropping it destroys the point of the conversation. |
92
147
  | 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. |
93
- | The final surface node | Never elided, even when trimming into the tail. |
148
+ | 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. |
149
+ | 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). |
94
150
 
95
151
  Within those bounds the policy is **oldest-first, least-long-possible**: the elided span starts at the oldest balanced cut
96
152
  and grows only until it frees exactly enough tokens.
97
153
 
154
+ Elision always starts at the **oldest** balanced cut, and the search is graded so that the cheapest loss is tried first:
155
+
156
+ 1. a span that stays **outside the retained tail** and keeps the **final message** (the configured retention, relaxed step
157
+ by step only if the fit otherwise fails);
158
+ 2. a span that may reach **into the retained tail**, still keeping the final message;
159
+ 3. **last resort** — a span that includes the final message, typically the current step's assistant tool-call plus its tool
160
+ result, which can only be removed as a pair.
161
+
162
+ Protecting the final message outright deadlocks the most common overflow shape: one large assistant tool-call whose tool
163
+ result is the last node cannot be removed as a pair, which left a handful of freeable tokens while the request stayed over
164
+ the wall (observed live: "largest balanced span frees ~4 of the ~4631 tokens needed"). The newest user message is the real
165
+ anchor and stays a hard barrier in every tier. With `allowTailTrim: false` the search ends after tier 1, so the retained
166
+ tail is a hard boundary and the final message is never dropped.
167
+
98
168
  ## Compatibility
99
169
 
100
170
  | Harness | State |
@@ -122,12 +192,17 @@ Override on the `context-trim` row of a profile patch (the bundle's own `cordis.
122
192
  | Key | Default | Meaning |
123
193
  |---|---|---|
124
194
  | `targetRatio` | `0.9` | `budget = floor((contextWindow - reserveOutputTokens) * targetRatio)` |
125
- | `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 |
126
196
  | `retainRatio` / `retainTokens` | `0.16` / — | Recent tail kept verbatim (mutually exclusive forms) |
127
197
  | `minTailTokens` | `2048` | Absolute floor for that tail |
128
198
  | `protectHeadNodes` | `1` | Leading nodes that are never trimmed |
129
- | `allowTailTrim` | `true` | Let the elided span reach into the retained tail when necessary |
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 |
130
200
  | `markerSlackTokens` | `64` | Slack added to the priced marker so the post-trim request stays under budget |
201
+ | `autoTrim` | `true` | Trim automatically on `CONTEXT_WINDOW_EXCEEDED`; never fires on ordinary compaction |
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 |
131
206
 
132
207
  ## Limits
133
208
 
@@ -141,8 +216,47 @@ Override on the `context-trim` row of a profile patch (the bundle's own `cordis.
141
216
  - **Heuristic pricing.** Budgets use the token meter's own estimate — the same numbers `/compact` and the GUI context bar
142
217
  use. Provider-reported usage drifts slightly from it.
143
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
+
144
241
  ## Development
145
242
 
243
+ ### End-to-end overflow check (no model needed)
244
+
245
+ `scripts/mock-overflow-server.mjs` is a stateful OpenAI-compatible endpoint that enforces a **real** limit lower than the
246
+ `contextWindow` the harness is told, and answers the first `TOOL_STEPS` requests with a tool call so one turn keeps
247
+ looping and grows past the real limit — the context wall, without a model switch:
248
+
249
+ ```bash
250
+ node scripts/mock-overflow-server.mjs & # PORT=4185 TOKEN_LIMIT=12000 TOOL_STEPS=4
251
+ # point an ISOLATED profile at it (provider with contextWindow 20000, baseURL .../v1), then:
252
+ DSH_HOME=$(mktemp -d) dsh --profile headless "..." # see the isolated-home procedure in dsh-plugin-packaging
253
+ ```
254
+
255
+ A passing run leaves this in the session log: `assistant/attempt` (the wall), then exactly one `compaction/prune` + one
256
+ `user/message` replacement, then a **succeeding** retry — and **zero** `compaction/start`, proving the request was
257
+ repaired by trimming and that summarisation never ran.
258
+
259
+
146
260
  ```bash
147
261
  npm install # the harness contracts this plugin builds on, pinned as devDependencies
148
262
  npm test # node --test
@@ -159,13 +273,15 @@ releases go out through `.github/workflows/publish.yml`, which is manual-only (`
159
273
 
160
274
  | Check | State |
161
275
  |---|---|
162
- | `npm test` (34 tests: planner, args, surface apply + log replay, plugin handler) | ✅ passing |
276
+ | `npm test` (72 tests: planner, args, surface apply + log replay, plugin handler, automatic overflow path, in-place slim, config) | ✅ passing |
163
277
  | Isolated `DSH_HOME` install (`dsh plugin add file:…`) reconciling dependency **and** bundle layer | ✅ verified |
164
278
  | Composed profile tree contains the `context-trim` insert row (`dsh --dump-config`) | ✅ verified |
165
279
  | Profile boot with the plugin mounted (no load error) | ✅ reaches the credential check cleanly |
166
- | Same suite against the pinned **published** harness packages (`npm ci`) | ✅ 34 passing |
280
+ | Same suite against the pinned **published** harness packages (`npm ci`) | ✅ 72 passing |
167
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 |
168
- | Same suite on harness 0.1.5-rc.2 (renamed marker + surface system prompt) | ✅ 40 passing |
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 |
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 |
169
285
  | CI workflow (Node 22 / 24) | ✅ green |
170
286
  | npm release via GitHub Actions | ✅ 0.1.0 published with provenance (`+ dsh-command-context-trim@0.1.0`) |
171
287
  | Isolated profile install **from the npm registry** (dependency + bundle layer + composed insert row) | ✅ 0.1.0 |
package/README.zh.md CHANGED
@@ -53,9 +53,48 @@ 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
+ 每轮溢出 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,直接命中。
86
+ 代价如实说:自动 trim 是**丢弃**最旧一段而不是摘要它——在小窗口下这正是要点,但被丢的内容只会变成一条占位标记。
87
+ `/compact` 仍在,原文也仍在会话日志里。
88
+
56
89
  ## 保护集与选段策略
57
90
 
58
- 保护:开头 `protectHeadNodes`(默认 1,即任务声明)、末尾最近 `retainRatio` 窗口(下限 `minTailTokens`)、以及最后一条消息永不裁剪。
91
+ 保护与**优先级**(永远从最旧处开始裁,按"损失最小"逐档尝试):
92
+
93
+ 1. 留在保留尾部之外,且**保留最后一条**(先按配置的保留量;实在放不下才逐级放宽保留量);
94
+ 2. 可以进入保留尾部,但仍**保留最后一条**;
95
+ 3. **最后一档**才允许把最后一条纳入——通常就是当前这步的 assistant tool-call 与它的 tool-result(两者只能成对移除)。
96
+
97
+ 另外:开头 `protectHeadNodes`(默认 1,即任务声明)与**最新的那条 `user/message`(你当前的指令)是硬屏障**——永不裁剪、也不被跨越。把最后一条按位置硬保护会卡死最常见的溢出形态(实测探针:`largest balanced span frees ~4 of the ~4631 tokens needed`)。`allowTailTrim: false` 时搜索在第 1 档后结束:保留尾部成为硬边界,最后一条永不丢弃。
59
98
  在保护集之间采用**最旧优先、够用即止**:从最旧的平衡切点开始,只增长到刚好释放够 token。
60
99
 
61
100
  ## 兼容性
@@ -98,8 +137,8 @@ npm run link:harness # 也可改为从本地 dsh 安装的依赖闭包解析 @
98
137
 
99
138
  已验证:34 个测试全部通过(选段算法、参数解析、真实 Session 上的 surface 改写与日志重放、插件命令注册与端到端裁剪,以及用**真实 `ctx.tokenMeter`** 验证「实测降幅 == 声明的 shadow price」和「新进程重放裁剪后日志得到完全一致的总量」);
100
139
  隔离 `DSH_HOME` 安装后 dependency 与 bundle 层均正确 reconcile;`dsh --dump-config` 中出现 `context-trim` 行;profile 启动无加载错误。
101
- CI 在 Node 22/24 上跑同一套测试;发布通过 `.github/workflows/publish.yml`(手动 `workflow_dispatch`)。
102
- npm 0.1.1 已发布(带 provenance),并已在隔离 profile 里从 registry 安装验证;尚未执行:Web GUI 里的真实小窗口端到端验证(mock provider 与隔离实例已就绪)。
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 与隔离实例已就绪)。
103
142
 
104
143
  ## License
105
144
 
package/cordis.patch.yml CHANGED
@@ -27,3 +27,25 @@
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
+ # 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
@@ -0,0 +1,112 @@
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
+ * 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
+ *
31
+ * @module dsh-command-context-trim/auto-trim
32
+ */
33
+ import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm';
34
+ import { logLineFor, describeError } from './render.js';
35
+ import { executeTrim } from './trim-session.js';
36
+
37
+ /**
38
+ * Register automatic context-overflow trimming.
39
+ * @param ctx - plugin context (token meter, LLM service, events, logger).
40
+ * @param config - resolved configuration.
41
+ * @returns nothing; listeners are owned by the plugin's fiber and disposed with it.
42
+ */
43
+ export function registerAutoTrim(ctx, config) {
44
+ if (config.autoTrim !== true) return;
45
+ /** agent -> automatic trims already spent in the current overflow episode. */
46
+ const spent = new WeakMap();
47
+ /** session -> agent, so a successful assistant message can reset the budget. */
48
+ const actors = new WeakMap();
49
+ ctx.on(
50
+ 'agent/request-error',
51
+ async ({ agent, failure, signal }, next) => {
52
+ if (failure?.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next();
53
+ const used = spent.get(agent) ?? 0;
54
+ if (used >= config.maxAutoTrimRetries) {
55
+ log(ctx, 'warn', `context-overflow auto-trim: retry budget spent (${used}); leaving recovery to compaction`);
56
+ return next();
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
+ }
75
+ const generation = agent.session.surface.replaceGeneration;
76
+ let outcome;
77
+ try {
78
+ outcome = await executeTrim(ctx, config, { agent, signal, routedOnly: true, budgetCeiling, inPlaceFirst: true });
79
+ } catch (error) {
80
+ log(ctx, 'warn', `context-overflow auto-trim failed (${describeError(error)}); leaving recovery to compaction`);
81
+ return next();
82
+ }
83
+ if (signal.aborted) return next();
84
+ if (agent.session.surface.replaceGeneration <= generation) {
85
+ log(ctx, 'warn', `context-overflow auto-trim: nothing safely trimmable; leaving recovery to compaction`);
86
+ return next();
87
+ }
88
+ spent.set(agent, used + 1);
89
+ actors.set(agent.session, agent);
90
+ log(ctx, 'warn', `context-overflow auto-trim: ${logLineFor(outcome)}`);
91
+ return { kind: 'retry' };
92
+ },
93
+ { prepend: true }
94
+ );
95
+ // Mirror compaction-basic's accounting: one overflow episode's budget resets
96
+ // once the conversation advances or the agent goes idle.
97
+ ctx.on('agent/status', ({ agent, status }) => {
98
+ if (status === 'idle') spent.delete(agent);
99
+ });
100
+ ctx.on('session/event', (session, event) => {
101
+ if (event.type !== 'assistant/message') return;
102
+ const agent = actors.get(session);
103
+ if (agent !== undefined) spent.delete(agent);
104
+ });
105
+ }
106
+
107
+ /** Log through the context logger when it is available. */
108
+ function log(ctx, level, message) {
109
+ const logger = ctx.logger;
110
+ if (logger?.[level] === undefined) return;
111
+ logger[level](`context-trim: ${message}`);
112
+ }
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,
@@ -15,7 +17,14 @@ export const DEFAULTS = Object.freeze({
15
17
  minTailTokens: 2048,
16
18
  protectHeadNodes: 1,
17
19
  allowTailTrim: true,
18
- markerSlackTokens: 64
20
+ markerSlackTokens: 64,
21
+ autoTrim: true,
22
+ maxAutoTrimRetries: 3,
23
+ autoTrimShrink: 0.5,
24
+ preferInPlacePrune: true,
25
+ pruneThresholdChars: 8192,
26
+ pruneHeadChars: 4096,
27
+ pruneTailChars: 1024
19
28
  });
20
29
 
21
30
  /** Every key this plugin accepts. */
@@ -27,7 +36,14 @@ const CONFIG_KEYS = new Set([
27
36
  'minTailTokens',
28
37
  'protectHeadNodes',
29
38
  'allowTailTrim',
30
- 'markerSlackTokens'
39
+ 'markerSlackTokens',
40
+ 'autoTrim',
41
+ 'maxAutoTrimRetries',
42
+ 'autoTrimShrink',
43
+ 'preferInPlacePrune',
44
+ 'pruneThresholdChars',
45
+ 'pruneHeadChars',
46
+ 'pruneTailChars'
31
47
  ]);
32
48
 
33
49
  /**
@@ -48,11 +64,31 @@ export function resolveConfig(config = {}) {
48
64
  const protectHeadNodes = config.protectHeadNodes ?? DEFAULTS.protectHeadNodes;
49
65
  const allowTailTrim = config.allowTailTrim ?? DEFAULTS.allowTailTrim;
50
66
  const markerSlackTokens = config.markerSlackTokens ?? DEFAULTS.markerSlackTokens;
67
+ const autoTrim = config.autoTrim ?? DEFAULTS.autoTrim;
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;
51
74
  assertRatio('targetRatio', targetRatio);
52
75
  assertNonNegativeInteger('reserveOutputTokens', reserveOutputTokens);
53
76
  assertNonNegativeInteger('minTailTokens', minTailTokens);
54
77
  assertNonNegativeInteger('protectHeadNodes', protectHeadNodes);
55
78
  assertNonNegativeInteger('markerSlackTokens', markerSlackTokens);
79
+ assertNonNegativeInteger('maxAutoTrimRetries', maxAutoTrimRetries);
80
+ assertRatio('autoTrimShrink', autoTrimShrink);
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
+ }
56
92
  if (typeof allowTailTrim !== 'boolean') throw new Error('ContextTrimConfig: allowTailTrim must be a boolean');
57
93
  const retention = resolveRetention(config);
58
94
  if (retention.retainRatio !== undefined && retention.retainRatio >= targetRatio) {
@@ -65,7 +101,14 @@ export function resolveConfig(config = {}) {
65
101
  minTailTokens,
66
102
  protectHeadNodes,
67
103
  allowTailTrim,
68
- markerSlackTokens
104
+ markerSlackTokens,
105
+ autoTrim,
106
+ maxAutoTrimRetries,
107
+ autoTrimShrink,
108
+ preferInPlacePrune,
109
+ pruneThresholdChars,
110
+ pruneHeadChars,
111
+ pruneTailChars
69
112
  });
70
113
  }
71
114
 
@@ -120,6 +163,12 @@ function assertRatio(name, value) {
120
163
  }
121
164
  }
122
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
+
123
172
  function assertNonNegativeInteger(name, value) {
124
173
  if (!Number.isInteger(value) || value < 0) {
125
174
  throw new Error(`ContextTrimConfig: ${name} (${String(value)}) must be a non-negative integer`);