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/lib/index.js
CHANGED
|
@@ -13,15 +13,19 @@
|
|
|
13
13
|
* synchronous appends, zero LLM calls, so it works precisely when every request
|
|
14
14
|
* is failing.
|
|
15
15
|
*
|
|
16
|
+
* The same execution also runs **automatically** on the context wall: a
|
|
17
|
+
* prepended `agent/request-error` listener trims on `CONTEXT_WINDOW_EXCEEDED` and
|
|
18
|
+
* retries, and only hands the problem to compaction (prune + summarize) when it
|
|
19
|
+
* cannot free anything — see `./auto-trim.js`.
|
|
20
|
+
*
|
|
16
21
|
* @module dsh-command-context-trim
|
|
17
22
|
*/
|
|
18
|
-
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction';
|
|
19
23
|
import z from '@deepseek-ai/schemastery';
|
|
20
24
|
import { parseTrimArguments, USAGE } from './args.js';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
+
import { registerAutoTrim } from './auto-trim.js';
|
|
26
|
+
import { resolveConfig } from './config.js';
|
|
27
|
+
import { describeError } from './render.js';
|
|
28
|
+
import { executeTrim } from './trim-session.js';
|
|
25
29
|
|
|
26
30
|
/** Cordis plugin name. */
|
|
27
31
|
export const name = 'context-trim';
|
|
@@ -29,7 +33,7 @@ export const name = 'context-trim';
|
|
|
29
33
|
/** Services required before the command can be registered. */
|
|
30
34
|
export const inject = ['commands', 'tokenMeter', 'llm'];
|
|
31
35
|
|
|
32
|
-
/** Loader-facing configuration shape; ranges are enforced by
|
|
36
|
+
/** Loader-facing configuration shape; ranges are enforced by `resolveConfig`. */
|
|
33
37
|
export const Config = z.object({
|
|
34
38
|
targetRatio: z.number(),
|
|
35
39
|
reserveOutputTokens: z.number(),
|
|
@@ -38,11 +42,14 @@ export const Config = z.object({
|
|
|
38
42
|
minTailTokens: z.number(),
|
|
39
43
|
protectHeadNodes: z.number(),
|
|
40
44
|
allowTailTrim: z.boolean(),
|
|
41
|
-
markerSlackTokens: z.number()
|
|
45
|
+
markerSlackTokens: z.number(),
|
|
46
|
+
autoTrim: z.boolean(),
|
|
47
|
+
maxAutoTrimRetries: z.number()
|
|
42
48
|
});
|
|
43
49
|
|
|
44
50
|
/**
|
|
45
|
-
* Register `/trim` for every composed human-command adapter
|
|
51
|
+
* Register `/trim` for every composed human-command adapter, and the automatic
|
|
52
|
+
* context-overflow handler for every agent.
|
|
46
53
|
* @param ctx - context carrying the command registry, token meter, and LLM service.
|
|
47
54
|
* @param config - untrusted plugin configuration.
|
|
48
55
|
*/
|
|
@@ -69,6 +76,7 @@ export function apply(ctx, config) {
|
|
|
69
76
|
handler
|
|
70
77
|
});
|
|
71
78
|
}, 'context-trim lifecycle');
|
|
79
|
+
registerAutoTrim(ctx, resolved);
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
/**
|
|
@@ -83,7 +91,17 @@ async function execute(ctx, config, invocation) {
|
|
|
83
91
|
if (parsed.error !== undefined) return { kind: 'error', text: `${parsed.error}\n${USAGE}` };
|
|
84
92
|
let running;
|
|
85
93
|
try {
|
|
86
|
-
running = invocation.agent.runMaintenance((agentSignal) =>
|
|
94
|
+
running = invocation.agent.runMaintenance(async (agentSignal) => {
|
|
95
|
+
const signal = AbortSignal.any([invocation.signal, agentSignal]);
|
|
96
|
+
const { result } = await executeTrim(ctx, config, {
|
|
97
|
+
agent: invocation.agent,
|
|
98
|
+
signal,
|
|
99
|
+
requestedRoute: parsed.route,
|
|
100
|
+
explicitBudget: parsed.budget,
|
|
101
|
+
check: parsed.check
|
|
102
|
+
});
|
|
103
|
+
return result;
|
|
104
|
+
});
|
|
87
105
|
} catch (error) {
|
|
88
106
|
return { kind: 'error', text: `Trim needs an idle agent: ${describeError(error)}` };
|
|
89
107
|
}
|
|
@@ -94,139 +112,3 @@ async function execute(ctx, config, invocation) {
|
|
|
94
112
|
return { kind: 'error', text: `Trim failed: ${describeError(error)}` };
|
|
95
113
|
}
|
|
96
114
|
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Plan and apply one trim; the whole session-visible mutation happens here.
|
|
100
|
-
* @param ctx - plugin context.
|
|
101
|
-
* @param config - resolved configuration.
|
|
102
|
-
* @param invocation - command invocation (agent, signal).
|
|
103
|
-
* @param parsed - parsed command arguments.
|
|
104
|
-
* @param agentSignal - cancellation owned by the maintenance reservation.
|
|
105
|
-
* @returns a command result.
|
|
106
|
-
*/
|
|
107
|
-
async function trimOnce(ctx, config, invocation, parsed, agentSignal) {
|
|
108
|
-
const signal = AbortSignal.any([invocation.signal, agentSignal]);
|
|
109
|
-
signal.throwIfAborted();
|
|
110
|
-
const session = invocation.agent.session;
|
|
111
|
-
assertNoOpenCompaction(session);
|
|
112
|
-
const target = parsed.budget === undefined ? await resolveTarget(ctx, invocation.agent, parsed.route, signal) : undefined;
|
|
113
|
-
const budget = parsed.budget ?? budgetFor(target.contextWindow, config);
|
|
114
|
-
const label = target === undefined ? `an explicit ${budget}-token budget` : `${target.label} (window ${target.contextWindow})`;
|
|
115
|
-
const header = target === undefined ? undefined : targetHeader(session, target);
|
|
116
|
-
const measurement = ctx.tokenMeter.measure(session, header);
|
|
117
|
-
const nodes = measurement.nodes.map((node) => ({ seq: node.seq, heuristicTokens: node.heuristicTokens }));
|
|
118
|
-
if (nodes.length === 0) {
|
|
119
|
-
return { kind: 'success', text: `Nothing to trim: ${session.id} has no model-visible messages yet.` };
|
|
120
|
-
}
|
|
121
|
-
// The retained tail scales with the capacity being fitted: the target window
|
|
122
|
-
// when one is known, otherwise the explicit budget itself.
|
|
123
|
-
const retainTokens = retentionFor(target?.contextWindow ?? budget, config);
|
|
124
|
-
const envelopeTokens = Math.max(0, measurement.totalTokens - measurement.surfaceTokens);
|
|
125
|
-
const planFor = (markerTokens) =>
|
|
126
|
-
planTrim({
|
|
127
|
-
nodes,
|
|
128
|
-
envelopeTokens,
|
|
129
|
-
budget,
|
|
130
|
-
markerCost: markerTokens + config.markerSlackTokens,
|
|
131
|
-
retainTokens,
|
|
132
|
-
minTailTokens: config.minTailTokens,
|
|
133
|
-
protectHeadNodes: config.protectHeadNodes,
|
|
134
|
-
allowTailTrim: config.allowTailTrim,
|
|
135
|
-
isBalancedBefore: (seq) => toolPairingBalancedBefore(session, seq),
|
|
136
|
-
isBalancedAfter: (seq) => toolPairingBalancedAfter(session, seq)
|
|
137
|
-
});
|
|
138
|
-
let markerTokens = ctx.tokenMeter.estimateMessage(provisionalMarker());
|
|
139
|
-
let plan = planFor(markerTokens);
|
|
140
|
-
if (plan.kind !== 'span') return describeNonSpan(plan, label);
|
|
141
|
-
let marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
|
|
142
|
-
const finalMarkerTokens = ctx.tokenMeter.estimateMessage(marker);
|
|
143
|
-
if (finalMarkerTokens > markerTokens) {
|
|
144
|
-
// The marker now carries real numbers; re-plan once so its own price is exact.
|
|
145
|
-
plan = planFor(finalMarkerTokens);
|
|
146
|
-
if (plan.kind !== 'span') return describeNonSpan(plan, label);
|
|
147
|
-
marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
|
|
148
|
-
markerTokens = finalMarkerTokens;
|
|
149
|
-
}
|
|
150
|
-
if (parsed.check) return { kind: 'success', text: preview(plan, label, markerTokens) };
|
|
151
|
-
const replacement = applyTrim(session, plan, marker);
|
|
152
|
-
const after = ctx.tokenMeter.measure(session, header);
|
|
153
|
-
return { kind: 'success', text: report(plan, after, label, markerTokens), sourceEventSeq: replacement.seq };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Refuse to rewrite a surface while a compaction bracket is open. */
|
|
157
|
-
function assertNoOpenCompaction(session) {
|
|
158
|
-
for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
|
|
159
|
-
const event = session.eventAt(seq);
|
|
160
|
-
if (event === undefined) continue;
|
|
161
|
-
if (event.type === 'compaction/end') return;
|
|
162
|
-
// A seed boundary proves any earlier unmatched start belongs to a previous lifecycle.
|
|
163
|
-
if (event.type === 'session/end-seed') return;
|
|
164
|
-
if (event.type === 'compaction/start') {
|
|
165
|
-
throw new Error('a compaction is already in progress in this session; wait for it to finish, then retry');
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Render every non-span outcome as a final command result. */
|
|
171
|
-
function describeNonSpan(plan, label) {
|
|
172
|
-
switch (plan.kind) {
|
|
173
|
-
case 'fits':
|
|
174
|
-
return {
|
|
175
|
-
kind: 'success',
|
|
176
|
-
text: `Already within budget: ~${plan.totalTokens} / ${plan.budget} tokens for ${label}. Nothing to trim.`
|
|
177
|
-
};
|
|
178
|
-
case 'envelope':
|
|
179
|
-
return {
|
|
180
|
-
kind: 'error',
|
|
181
|
-
text: [
|
|
182
|
-
`Fixed request overhead alone (~${plan.envelopeTokens} tokens of system prompt and tool schemas) exceeds the ${plan.budget}-token budget for ${label}.`,
|
|
183
|
-
'Trimming conversation history cannot help: raise the backend context size (the model\'s contextWindow in settings.yaml, or the server\'s context flag) or reduce mounted tools and skills, then retry.'
|
|
184
|
-
].join('\n')
|
|
185
|
-
};
|
|
186
|
-
case 'no-span':
|
|
187
|
-
return {
|
|
188
|
-
kind: 'error',
|
|
189
|
-
text: `Nothing safely trimmable for ${label}: ${plan.reason}, or no tool-pairing balanced cut exists.`
|
|
190
|
-
};
|
|
191
|
-
case 'insufficient':
|
|
192
|
-
return {
|
|
193
|
-
kind: 'error',
|
|
194
|
-
text: [
|
|
195
|
-
`Cannot free enough for ${label}: the largest balanced span frees ~${plan.maxFreeable} of the ~${plan.need} tokens needed.`,
|
|
196
|
-
`Protected content: task statement ~${plan.protectedHeadTokens} tokens, recent tail ~${plan.protectedTailTokens} tokens (retain target ~${plan.retainTokens}).`,
|
|
197
|
-
'Try /compact (it summarizes instead of dropping), a larger window, or /trim with an explicit budget after another reduction.'
|
|
198
|
-
].join('\n')
|
|
199
|
-
};
|
|
200
|
-
default:
|
|
201
|
-
return { kind: 'error', text: `Trim could not plan a reduction (${String(plan.kind)}).` };
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/** Render a dry run. */
|
|
206
|
-
function preview(plan, label, markerTokens) {
|
|
207
|
-
return [
|
|
208
|
-
`Would trim ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
|
|
209
|
-
`Request size: ~${plan.totalTokens} → ~${plan.projectedTotal} tokens (target ${plan.budget}). Nothing was changed.`
|
|
210
|
-
].join('\n');
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Render a committed trim against the re-measured request. */
|
|
214
|
-
function report(plan, after, label, markerTokens) {
|
|
215
|
-
const lines = [
|
|
216
|
-
`Trimmed ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
|
|
217
|
-
`Request size: ~${plan.totalTokens} → ~${after.totalTokens} tokens (target ${plan.budget}).`
|
|
218
|
-
];
|
|
219
|
-
if (plan.relaxedRetention) {
|
|
220
|
-
lines.push(`Retention relaxed to ~${plan.retainTokens} tokens to reach the budget.`);
|
|
221
|
-
}
|
|
222
|
-
return lines.join('\n');
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Render a thrown value without trusting its string coercion. */
|
|
226
|
-
function describeError(error) {
|
|
227
|
-
try {
|
|
228
|
-
return error instanceof Error ? error.message : String(error);
|
|
229
|
-
} catch {
|
|
230
|
-
return '<unrenderable thrown value>';
|
|
231
|
-
}
|
|
232
|
-
}
|
package/lib/plan.js
CHANGED
|
@@ -8,6 +8,21 @@
|
|
|
8
8
|
* and the original request are the two pieces of high-value context; everything
|
|
9
9
|
* between them is what a smaller window can afford to lose.
|
|
10
10
|
*
|
|
11
|
+
* Two node classes are never elidable:
|
|
12
|
+
*
|
|
13
|
+
* - **Barriers** (`barrier: true`) — the system prompt. Harness 0.1.5 moved it
|
|
14
|
+
* from the request header onto the surface as node 0, which makes it
|
|
15
|
+
* *trimmable* by position: dropping it would strip the model's instructions,
|
|
16
|
+
* and letting it consume the head-protection budget would expose the user's
|
|
17
|
+
* original request instead. Barriers are therefore untouchable and split the
|
|
18
|
+
* elidable space into regions, and `protectHeadNodes` counts only non-barrier
|
|
19
|
+
* nodes so it keeps protecting the task statement.
|
|
20
|
+
* - **The newest `user/message`** — the live human instruction — plus the
|
|
21
|
+
* retained tail. An earlier rule protected the final surface node outright,
|
|
22
|
+
* which deadlocked exactly the common overflow shape: a large assistant
|
|
23
|
+
* tool-call whose tool result is the last node could not be removed as a pair,
|
|
24
|
+
* leaving only a few tokens freeable while the request stayed over the wall.
|
|
25
|
+
*
|
|
11
26
|
* Nothing here touches a session: the planner receives measured node prices and
|
|
12
27
|
* two balance predicates, so every branch is directly testable.
|
|
13
28
|
*
|
|
@@ -18,17 +33,21 @@
|
|
|
18
33
|
* @typedef {object} TrimNode
|
|
19
34
|
* @property {number} seq - surface event sequence of the node.
|
|
20
35
|
* @property {number} heuristicTokens - the token meter's heuristic price for it.
|
|
36
|
+
* @property {boolean} [barrier] - true for a node that may never be elided and
|
|
37
|
+
* that no elided span may cross (the system prompt).
|
|
38
|
+
* @property {boolean} [userMessage] - true for human prompts; the newest one is
|
|
39
|
+
* treated as a barrier so an ongoing instruction is never elided.
|
|
21
40
|
*/
|
|
22
41
|
|
|
23
42
|
/**
|
|
24
43
|
* @typedef {object} TrimPlanInput
|
|
25
44
|
* @property {readonly TrimNode[]} nodes - current surface nodes in model-visible order.
|
|
26
|
-
* @property {number} envelopeTokens - non-surface request price (
|
|
45
|
+
* @property {number} envelopeTokens - non-surface request price (tool schemas and other fixed request data).
|
|
27
46
|
* @property {number} budget - target total request size in tokens.
|
|
28
47
|
* @property {number} markerCost - priced replacement marker, including configured slack.
|
|
29
48
|
* @property {number} retainTokens - preferred verbatim recent-tail budget.
|
|
30
49
|
* @property {number} minTailTokens - absolute floor for that retained tail.
|
|
31
|
-
* @property {number} protectHeadNodes - leading nodes that must never be elided.
|
|
50
|
+
* @property {number} protectHeadNodes - leading non-barrier nodes that must never be elided.
|
|
32
51
|
* @property {boolean} allowTailTrim - whether the elided span may reach into the retained tail.
|
|
33
52
|
* @property {(seq: number) => boolean} isBalancedBefore - tool-pairing balance before a node.
|
|
34
53
|
* @property {(seq: number) => boolean} isBalancedAfter - tool-pairing balance after a node.
|
|
@@ -45,7 +64,8 @@ export function planTrim(input) {
|
|
|
45
64
|
totalTokens: input.envelopeTokens + surfaceTokens,
|
|
46
65
|
surfaceTokens,
|
|
47
66
|
envelopeTokens: input.envelopeTokens,
|
|
48
|
-
budget: input.budget
|
|
67
|
+
budget: input.budget,
|
|
68
|
+
hasBarrier: input.nodes.some((node) => node.barrier === true)
|
|
49
69
|
};
|
|
50
70
|
if (common.totalTokens <= input.budget) return { kind: 'fits', ...common };
|
|
51
71
|
if (input.envelopeTokens >= input.budget) return { kind: 'envelope', ...common };
|
|
@@ -55,32 +75,67 @@ export function planTrim(input) {
|
|
|
55
75
|
const need = common.totalTokens - input.budget;
|
|
56
76
|
const configuredRetention = input.retainTokens;
|
|
57
77
|
let weakest = null;
|
|
58
|
-
for (const
|
|
59
|
-
const attempt = attemptSpan({ ...input,
|
|
78
|
+
for (const configuration of attemptConfigurations(input, configuredRetention)) {
|
|
79
|
+
const attempt = attemptSpan({ ...input, ...configuration, need });
|
|
60
80
|
if (attempt.kind === 'span') {
|
|
61
81
|
return {
|
|
62
82
|
kind: 'span',
|
|
63
83
|
...common,
|
|
64
84
|
...attempt,
|
|
65
85
|
need,
|
|
66
|
-
retainTokens,
|
|
67
|
-
relaxedRetention: retainTokens < configuredRetention,
|
|
86
|
+
retainTokens: configuration.retainTokens,
|
|
87
|
+
relaxedRetention: configuration.retainTokens < configuredRetention,
|
|
88
|
+
reachedFinalNode: configuration.allowFinalNode === true,
|
|
68
89
|
projectedTotal: common.totalTokens - attempt.freedTokens
|
|
69
90
|
};
|
|
70
91
|
}
|
|
71
|
-
if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = { ...attempt, retainTokens };
|
|
92
|
+
if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = { ...attempt, retainTokens: configuration.retainTokens };
|
|
72
93
|
}
|
|
73
94
|
return {
|
|
74
95
|
kind: 'insufficient',
|
|
75
96
|
...common,
|
|
76
97
|
need,
|
|
77
98
|
maxFreeable: weakest?.maxFreeable ?? 0,
|
|
78
|
-
protectedHeadTokens:
|
|
99
|
+
protectedHeadTokens: protectedHeadTokens(input.nodes, input.protectHeadNodes),
|
|
79
100
|
protectedTailTokens: weakest?.protectedTailTokens ?? 0,
|
|
101
|
+
protectedUserTokens: weakest?.protectedUserTokens ?? 0,
|
|
80
102
|
retainTokens: configuredRetention
|
|
81
103
|
};
|
|
82
104
|
}
|
|
83
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The search order, most conservative first. The final surface node is a
|
|
108
|
+
* *preference*, not a law: keeping it is always tried before dropping it, and
|
|
109
|
+
* dropping it happens only when no other span can free enough.
|
|
110
|
+
*
|
|
111
|
+
* 1. stay outside the retained tail and keep the final node (the configured
|
|
112
|
+
* retention, relaxed step by step only if the fit otherwise fails);
|
|
113
|
+
* 2. may reach into the retained tail, still keeping the final node;
|
|
114
|
+
* 3. last resort: a span that includes the final node (typically the current
|
|
115
|
+
* step's assistant tool-call plus its tool result, which can only be removed
|
|
116
|
+
* as a pair).
|
|
117
|
+
*
|
|
118
|
+
* Elision always starts at the oldest balanced cut, so within every tier the
|
|
119
|
+
* oldest content goes first. A strict `allowTailTrim: false` ends the list after
|
|
120
|
+
* tier 1: the retained tail then is a hard boundary.
|
|
121
|
+
* @param input - planning input.
|
|
122
|
+
* @param configuredRetention - the preferred verbatim recent-tail budget.
|
|
123
|
+
* @returns ordered attempt configurations.
|
|
124
|
+
*/
|
|
125
|
+
function attemptConfigurations(input, configuredRetention) {
|
|
126
|
+
const insideTail = retentionLadder(configuredRetention, input.minTailTokens).map((retainTokens) => ({
|
|
127
|
+
retainTokens,
|
|
128
|
+
tailHard: true,
|
|
129
|
+
allowFinalNode: false
|
|
130
|
+
}));
|
|
131
|
+
if (input.allowTailTrim !== true) return insideTail;
|
|
132
|
+
return [
|
|
133
|
+
...insideTail,
|
|
134
|
+
{ retainTokens: configuredRetention, tailHard: false, allowFinalNode: false },
|
|
135
|
+
{ retainTokens: configuredRetention, tailHard: false, allowFinalNode: true }
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
|
|
84
139
|
/**
|
|
85
140
|
* Try one retention budget, returning the smallest oldest-anchored balanced span
|
|
86
141
|
* that frees at least `need` tokens.
|
|
@@ -90,26 +145,52 @@ export function planTrim(input) {
|
|
|
90
145
|
function attemptSpan(input) {
|
|
91
146
|
const nodes = input.nodes;
|
|
92
147
|
const tailStart = tailStartIndex(nodes, input.retainTokens);
|
|
93
|
-
|
|
148
|
+
// The newest human instruction is the anchor: it must never be elided, and no
|
|
149
|
+
// span may cross it.
|
|
150
|
+
const newestUserIndex = newestUserMessageIndex(nodes);
|
|
151
|
+
// How far right a span may reach: a hard tail boundary when this attempt keeps
|
|
152
|
+
// the retained tail, otherwise the end of the surface.
|
|
153
|
+
const tailFloor = input.tailHard === true ? Math.min(nodes.length - 1, tailStart - 1) : nodes.length - 1;
|
|
154
|
+
// The final node is kept unless this attempt is the last-resort tier.
|
|
155
|
+
const lastElidable = input.allowFinalNode === true ? tailFloor : Math.min(tailFloor, nodes.length - 2);
|
|
94
156
|
const shortfall = (maxFreeable) => ({
|
|
95
157
|
kind: 'shortfall',
|
|
96
158
|
maxFreeable,
|
|
97
159
|
protectedTailTokens: tailTokens(nodes, tailStart),
|
|
98
|
-
protectedHeadTokens:
|
|
160
|
+
protectedHeadTokens: protectedHeadTokens(nodes, input.protectHeadNodes),
|
|
161
|
+
protectedUserTokens: newestUserIndex === -1 ? 0 : nodes[newestUserIndex].heuristicTokens
|
|
99
162
|
});
|
|
100
|
-
const
|
|
101
|
-
if (lastElidable <
|
|
163
|
+
const headEnd = protectedHeadEnd(nodes, input.protectHeadNodes);
|
|
164
|
+
if (lastElidable < headEnd) return shortfall(0);
|
|
165
|
+
let weakest = null;
|
|
166
|
+
for (const region of elidableRegions(nodes, headEnd, lastElidable, newestUserIndex)) {
|
|
167
|
+
const attempt = attemptRegion(nodes, region, input);
|
|
168
|
+
if (attempt === null) continue;
|
|
169
|
+
if (attempt.kind === 'span') return attempt;
|
|
170
|
+
if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = attempt;
|
|
171
|
+
}
|
|
172
|
+
return shortfall(weakest?.maxFreeable ?? 0);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Grow the smallest balanced span from one region's oldest balanced cut.
|
|
177
|
+
* @param nodes - full priced surface.
|
|
178
|
+
* @param region - inclusive index range free of barriers.
|
|
179
|
+
* @param input - planning input carrying `need`, `markerCost`, and the balance predicates.
|
|
180
|
+
* @returns a `span` plan, or this region's best `shortfall`.
|
|
181
|
+
*/
|
|
182
|
+
function attemptRegion(nodes, region, input) {
|
|
102
183
|
let start = -1;
|
|
103
|
-
for (let index =
|
|
184
|
+
for (let index = region.start; index <= region.end; index += 1) {
|
|
104
185
|
if (input.isBalancedBefore(nodes[index].seq)) {
|
|
105
186
|
start = index;
|
|
106
187
|
break;
|
|
107
188
|
}
|
|
108
189
|
}
|
|
109
|
-
if (start === -1) return
|
|
190
|
+
if (start === -1) return null;
|
|
110
191
|
let accumulated = 0;
|
|
111
192
|
let best = null;
|
|
112
|
-
for (let index = start; index <=
|
|
193
|
+
for (let index = start; index <= region.end; index += 1) {
|
|
113
194
|
accumulated += nodes[index].heuristicTokens;
|
|
114
195
|
if (!input.isBalancedAfter(nodes[index].seq)) continue;
|
|
115
196
|
const freedTokens = accumulated - input.markerCost;
|
|
@@ -126,9 +207,48 @@ function attemptSpan(input) {
|
|
|
126
207
|
}
|
|
127
208
|
if (freedTokens >= input.need) break;
|
|
128
209
|
}
|
|
129
|
-
return best === null ?
|
|
130
|
-
|
|
131
|
-
|
|
210
|
+
return best === null ? null : best.freedTokens >= input.need ? { kind: 'span', ...best } : { kind: 'shortfall', maxFreeable: best.freedTokens };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Index just past the `protectHeadNodes`-th non-barrier node. */
|
|
214
|
+
function protectedHeadEnd(nodes, protectHeadNodes) {
|
|
215
|
+
let counted = 0;
|
|
216
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
217
|
+
if (nodes[index].barrier === true) continue;
|
|
218
|
+
counted += 1;
|
|
219
|
+
if (counted >= protectHeadNodes) return index + 1;
|
|
220
|
+
}
|
|
221
|
+
return nodes.length;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Index of the newest human prompt on the surface, or -1 when there is none. */
|
|
225
|
+
function newestUserMessageIndex(nodes) {
|
|
226
|
+
let found = -1;
|
|
227
|
+
for (let index = 0; index < nodes.length; index += 1) if (nodes[index].userMessage === true) found = index;
|
|
228
|
+
return found;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Maximal runs of elidable indices inside `[from, to]`.
|
|
233
|
+
* @param nodes - full priced surface.
|
|
234
|
+
* @param from - inclusive first index to consider.
|
|
235
|
+
* @param to - inclusive last index to consider.
|
|
236
|
+
* @param extraBarrier - one additional index treated as a barrier (the newest user message).
|
|
237
|
+
* @returns contiguous index runs a span may occupy.
|
|
238
|
+
*/
|
|
239
|
+
function elidableRegions(nodes, from, to, extraBarrier) {
|
|
240
|
+
const regions = [];
|
|
241
|
+
let start = null;
|
|
242
|
+
for (let index = from; index <= to; index += 1) {
|
|
243
|
+
if (nodes[index].barrier === true || index === extraBarrier) {
|
|
244
|
+
if (start !== null) regions.push({ start, end: index - 1 });
|
|
245
|
+
start = null;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (start === null) start = index;
|
|
249
|
+
}
|
|
250
|
+
if (start !== null) regions.push({ start, end: to });
|
|
251
|
+
return regions;
|
|
132
252
|
}
|
|
133
253
|
|
|
134
254
|
/**
|
|
@@ -162,9 +282,9 @@ function tailTokens(nodes, tailStart) {
|
|
|
162
282
|
return total;
|
|
163
283
|
}
|
|
164
284
|
|
|
165
|
-
/** Tokens held by the protected
|
|
166
|
-
function
|
|
285
|
+
/** Tokens held by the protected prefix, barriers included. */
|
|
286
|
+
function protectedHeadTokens(nodes, protectHeadNodes) {
|
|
167
287
|
let total = 0;
|
|
168
|
-
for (let index = 0; index <
|
|
288
|
+
for (let index = 0; index < protectedHeadEnd(nodes, protectHeadNodes); index += 1) total += nodes[index].heuristicTokens;
|
|
169
289
|
return total;
|
|
170
290
|
}
|
package/lib/render.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-result and log rendering, shared by the `/trim` command and the
|
|
3
|
+
* automatic context-overflow path.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-command-context-trim/render
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Render every non-span planning outcome as a final command result.
|
|
10
|
+
* @param plan - the plan returned by `planTrim`.
|
|
11
|
+
* @param label - human label of the target route.
|
|
12
|
+
* @returns a command result.
|
|
13
|
+
*/
|
|
14
|
+
export function describeNonSpan(plan, label) {
|
|
15
|
+
switch (plan.kind) {
|
|
16
|
+
case 'fits':
|
|
17
|
+
return {
|
|
18
|
+
kind: 'success',
|
|
19
|
+
text: `Already within budget: ~${plan.totalTokens} / ${plan.budget} tokens for ${label}. Nothing to trim.`
|
|
20
|
+
};
|
|
21
|
+
case 'envelope':
|
|
22
|
+
return {
|
|
23
|
+
kind: 'error',
|
|
24
|
+
text: [
|
|
25
|
+
`Fixed request overhead alone (~${plan.envelopeTokens} tokens of tool schemas and other non-surface request data) exceeds the ${plan.budget}-token budget for ${label}.`,
|
|
26
|
+
'Trimming conversation history cannot help: raise the backend context size (the model\'s contextWindow in settings.yaml, or the server\'s context flag) or reduce mounted tools and skills, then retry.'
|
|
27
|
+
].join('\n')
|
|
28
|
+
};
|
|
29
|
+
case 'no-span':
|
|
30
|
+
return {
|
|
31
|
+
kind: 'error',
|
|
32
|
+
text: `Nothing safely trimmable for ${label}: ${plan.reason}, or no tool-pairing balanced cut exists.`
|
|
33
|
+
};
|
|
34
|
+
case 'insufficient':
|
|
35
|
+
return {
|
|
36
|
+
kind: 'error',
|
|
37
|
+
text: [
|
|
38
|
+
`Cannot free enough for ${label}: the largest balanced span frees ~${plan.maxFreeable} of the ~${plan.need} tokens needed.`,
|
|
39
|
+
`Protected content: task statement ~${plan.protectedHeadTokens} tokens, recent tail ~${plan.protectedTailTokens} tokens (retain target ~${plan.retainTokens})` +
|
|
40
|
+
`${plan.protectedUserTokens > 0 ? `, newest instruction ~${plan.protectedUserTokens} tokens (never elided)` : ''}` +
|
|
41
|
+
`${plan.hasBarrier ? ', plus the system prompt, which is never trimmed' : ''}.`,
|
|
42
|
+
'Try /compact (it summarizes instead of dropping), a larger window, or /trim with an explicit budget after another reduction.'
|
|
43
|
+
].join('\n')
|
|
44
|
+
};
|
|
45
|
+
default:
|
|
46
|
+
return { kind: 'error', text: `Trim could not plan a reduction (${String(plan.kind)}).` };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Render a dry run.
|
|
52
|
+
* @param plan - a committed span plan.
|
|
53
|
+
* @param label - human label of the target route.
|
|
54
|
+
* @param markerTokens - measured price of the replacement marker.
|
|
55
|
+
* @returns the dry-run text.
|
|
56
|
+
*/
|
|
57
|
+
export function preview(plan, label, markerTokens) {
|
|
58
|
+
return [
|
|
59
|
+
`Would trim ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
|
|
60
|
+
`Request size: ~${plan.totalTokens} → ~${plan.projectedTotal} tokens (target ${plan.budget}). Nothing was changed.`,
|
|
61
|
+
...(plan.reachedFinalNode === true
|
|
62
|
+
? ['Note: nothing else can free enough — this plan has to remove the final surface message as a tool-call/result pair.']
|
|
63
|
+
: [])
|
|
64
|
+
].join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Render a committed trim against the re-measured request.
|
|
69
|
+
* @param plan - the committed span plan.
|
|
70
|
+
* @param after - post-trim measurement.
|
|
71
|
+
* @param label - human label of the target route.
|
|
72
|
+
* @param markerTokens - measured price of the replacement marker.
|
|
73
|
+
* @returns the report text.
|
|
74
|
+
*/
|
|
75
|
+
export function report(plan, after, label, markerTokens) {
|
|
76
|
+
const lines = [
|
|
77
|
+
`Trimmed ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
|
|
78
|
+
`Request size: ~${plan.totalTokens} → ~${after.totalTokens} tokens (target ${plan.budget}).`
|
|
79
|
+
];
|
|
80
|
+
if (plan.relaxedRetention) {
|
|
81
|
+
lines.push(`Retention relaxed to ~${plan.retainTokens} tokens to reach the budget.`);
|
|
82
|
+
}
|
|
83
|
+
if (plan.reachedFinalNode === true) {
|
|
84
|
+
lines.push('Nothing else could free enough: the final surface message was removed as part of a tool-call/result pair.');
|
|
85
|
+
}
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Render a committed trim as one log line, for the automatic path.
|
|
91
|
+
* @param plan - the committed span plan.
|
|
92
|
+
* @param after - post-trim measurement.
|
|
93
|
+
* @param label - human label of the target route.
|
|
94
|
+
* @returns a single-line account.
|
|
95
|
+
*/
|
|
96
|
+
export function logLine(plan, after, label) {
|
|
97
|
+
return (
|
|
98
|
+
`freed ~${plan.freedTokens} tokens over ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}) for ${label}; ` +
|
|
99
|
+
`request ~${plan.totalTokens} → ~${after.totalTokens} tokens (budget ${plan.budget})` +
|
|
100
|
+
`${plan.reachedFinalNode === true ? ' (last resort: the final message went as a tool-call/result pair)' : ''}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Render a thrown value without trusting its string coercion.
|
|
106
|
+
* @param error - the caught value (`unknown` in catch clauses).
|
|
107
|
+
* @returns a printable message.
|
|
108
|
+
*/
|
|
109
|
+
export function describeError(error) {
|
|
110
|
+
try {
|
|
111
|
+
return error instanceof Error ? error.message : String(error);
|
|
112
|
+
} catch {
|
|
113
|
+
return '<unrenderable thrown value>';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version tolerance for the harness's positional replacement marker.
|
|
3
|
+
*
|
|
4
|
+
* DeepSeek Harness renamed the surface-replacement keys in 0.1.5
|
|
5
|
+
* (`{op:'replace', start, end}` → `{op:'replace', startSeq, endSeq}`), and the
|
|
6
|
+
* session rejects any other shape at append time. Rather than parse a version
|
|
7
|
+
* string, the accepted shape is probed once against the harness actually
|
|
8
|
+
* installed: a throwaway detached session appends one replacement with each
|
|
9
|
+
* candidate and reports the first accepted.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-command-context-trim/session-compat
|
|
12
|
+
*/
|
|
13
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
14
|
+
import { Session } from '@deepseek-ai/dsh-session';
|
|
15
|
+
|
|
16
|
+
/** Candidate key sets, newest harness first. */
|
|
17
|
+
const CANDIDATES = Object.freeze([
|
|
18
|
+
Object.freeze({ start: 'startSeq', end: 'endSeq' }),
|
|
19
|
+
Object.freeze({ start: 'start', end: 'end' })
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/** Cached probe result for the installed harness. */
|
|
23
|
+
let cached;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The replacement-marker keys this harness accepts.
|
|
27
|
+
* @returns `{ start, end }` key names.
|
|
28
|
+
* @throws when the harness accepts none of the known shapes.
|
|
29
|
+
*/
|
|
30
|
+
export function replaceKeys() {
|
|
31
|
+
cached ??= detectReplaceKeys();
|
|
32
|
+
return cached;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Probe every known marker shape against a detached session.
|
|
37
|
+
* @returns the first accepted key set.
|
|
38
|
+
* @throws when no known shape is accepted.
|
|
39
|
+
*/
|
|
40
|
+
export function detectReplaceKeys() {
|
|
41
|
+
for (const candidate of CANDIDATES) {
|
|
42
|
+
if (acceptsReplacement(candidate)) return candidate;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(
|
|
45
|
+
'context-trim: this harness build accepts neither the 0.1.5+ nor the legacy positional replacement marker; ' +
|
|
46
|
+
'report it with the installed @deepseek-ai/dsh-session version'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the positional replacement marker in the harness's own key spelling.
|
|
52
|
+
* @param keys - key set returned by {@link replaceKeys}.
|
|
53
|
+
* @param startSeq - inclusive first shadowed surface seq.
|
|
54
|
+
* @param endSeq - inclusive last shadowed surface seq.
|
|
55
|
+
* @returns the marker to pass as `surfaceOp`.
|
|
56
|
+
*/
|
|
57
|
+
export function replacementOp(keys, startSeq, endSeq) {
|
|
58
|
+
return { op: 'replace', [keys.start]: startSeq, [keys.end]: endSeq };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether one detached session accepts a replacement written with these keys. */
|
|
62
|
+
function acceptsReplacement(keys) {
|
|
63
|
+
try {
|
|
64
|
+
const session = Session.create('context-trim-probe');
|
|
65
|
+
session.append('user/message', probeMessage('probe first'), { surfaceOp: 'append' });
|
|
66
|
+
session.append('user/message', probeMessage('probe second'), { surfaceOp: 'append' });
|
|
67
|
+
session.append('user/message', probeMessage('probe marker'), {
|
|
68
|
+
surfaceOp: replacementOp(keys, 1, 1),
|
|
69
|
+
sourceEventSeqs: [1]
|
|
70
|
+
});
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One frozen probe message; detached sessions never publish it. */
|
|
78
|
+
function probeMessage(text) {
|
|
79
|
+
return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'context-trim-probe' } });
|
|
80
|
+
}
|