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 +100 -1
- package/README.md +122 -6
- package/README.zh.md +42 -3
- package/cordis.patch.yml +22 -0
- package/lib/auto-trim.js +112 -0
- package/lib/config.js +52 -3
- package/lib/index.js +27 -154
- package/lib/plan.js +75 -12
- package/lib/prune-first.js +131 -0
- package/lib/render.js +133 -0
- package/lib/target.js +18 -2
- package/lib/trim-session.js +166 -0
- package/package.json +4 -3
package/lib/index.js
CHANGED
|
@@ -13,16 +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 { replaceKeys } from './session-compat.js';
|
|
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';
|
|
26
29
|
|
|
27
30
|
/** Cordis plugin name. */
|
|
28
31
|
export const name = 'context-trim';
|
|
@@ -30,7 +33,7 @@ export const name = 'context-trim';
|
|
|
30
33
|
/** Services required before the command can be registered. */
|
|
31
34
|
export const inject = ['commands', 'tokenMeter', 'llm'];
|
|
32
35
|
|
|
33
|
-
/** Loader-facing configuration shape; ranges are enforced by
|
|
36
|
+
/** Loader-facing configuration shape; ranges are enforced by `resolveConfig`. */
|
|
34
37
|
export const Config = z.object({
|
|
35
38
|
targetRatio: z.number(),
|
|
36
39
|
reserveOutputTokens: z.number(),
|
|
@@ -39,11 +42,14 @@ export const Config = z.object({
|
|
|
39
42
|
minTailTokens: z.number(),
|
|
40
43
|
protectHeadNodes: z.number(),
|
|
41
44
|
allowTailTrim: z.boolean(),
|
|
42
|
-
markerSlackTokens: z.number()
|
|
45
|
+
markerSlackTokens: z.number(),
|
|
46
|
+
autoTrim: z.boolean(),
|
|
47
|
+
maxAutoTrimRetries: z.number()
|
|
43
48
|
});
|
|
44
49
|
|
|
45
50
|
/**
|
|
46
|
-
* 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.
|
|
47
53
|
* @param ctx - context carrying the command registry, token meter, and LLM service.
|
|
48
54
|
* @param config - untrusted plugin configuration.
|
|
49
55
|
*/
|
|
@@ -70,6 +76,7 @@ export function apply(ctx, config) {
|
|
|
70
76
|
handler
|
|
71
77
|
});
|
|
72
78
|
}, 'context-trim lifecycle');
|
|
79
|
+
registerAutoTrim(ctx, resolved);
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
/**
|
|
@@ -84,7 +91,17 @@ async function execute(ctx, config, invocation) {
|
|
|
84
91
|
if (parsed.error !== undefined) return { kind: 'error', text: `${parsed.error}\n${USAGE}` };
|
|
85
92
|
let running;
|
|
86
93
|
try {
|
|
87
|
-
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
|
+
});
|
|
88
105
|
} catch (error) {
|
|
89
106
|
return { kind: 'error', text: `Trim needs an idle agent: ${describeError(error)}` };
|
|
90
107
|
}
|
|
@@ -95,147 +112,3 @@ async function execute(ctx, config, invocation) {
|
|
|
95
112
|
return { kind: 'error', text: `Trim failed: ${describeError(error)}` };
|
|
96
113
|
}
|
|
97
114
|
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Plan and apply one trim; the whole session-visible mutation happens here.
|
|
101
|
-
* @param ctx - plugin context.
|
|
102
|
-
* @param config - resolved configuration.
|
|
103
|
-
* @param invocation - command invocation (agent, signal).
|
|
104
|
-
* @param parsed - parsed command arguments.
|
|
105
|
-
* @param agentSignal - cancellation owned by the maintenance reservation.
|
|
106
|
-
* @returns a command result.
|
|
107
|
-
*/
|
|
108
|
-
async function trimOnce(ctx, config, invocation, parsed, agentSignal) {
|
|
109
|
-
const signal = AbortSignal.any([invocation.signal, agentSignal]);
|
|
110
|
-
signal.throwIfAborted();
|
|
111
|
-
const session = invocation.agent.session;
|
|
112
|
-
assertNoOpenCompaction(session);
|
|
113
|
-
const target = parsed.budget === undefined ? await resolveTarget(ctx, invocation.agent, parsed.route, signal) : undefined;
|
|
114
|
-
const budget = parsed.budget ?? budgetFor(target.contextWindow, config);
|
|
115
|
-
const label = target === undefined ? `an explicit ${budget}-token budget` : `${target.label} (window ${target.contextWindow})`;
|
|
116
|
-
const header = target === undefined ? undefined : targetHeader(session, target);
|
|
117
|
-
const measurement = ctx.tokenMeter.measure(session, header);
|
|
118
|
-
const nodes = measurement.nodes.map((node) => ({
|
|
119
|
-
seq: node.seq,
|
|
120
|
-
heuristicTokens: node.heuristicTokens,
|
|
121
|
-
// Harness 0.1.5+ carries the system prompt as surface node 0. It is never
|
|
122
|
-
// elidable, and no elided span may cross it: dropping it would strip the
|
|
123
|
-
// model's instructions, and letting it consume head protection would expose
|
|
124
|
-
// the user's original request instead.
|
|
125
|
-
...(session.eventAt(node.seq)?.type === 'system/message' ? { barrier: true } : {})
|
|
126
|
-
}));
|
|
127
|
-
if (nodes.length === 0) {
|
|
128
|
-
return { kind: 'success', text: `Nothing to trim: ${session.id} has no model-visible messages yet.` };
|
|
129
|
-
}
|
|
130
|
-
// The retained tail scales with the capacity being fitted: the target window
|
|
131
|
-
// when one is known, otherwise the explicit budget itself.
|
|
132
|
-
const retainTokens = retentionFor(target?.contextWindow ?? budget, config);
|
|
133
|
-
const envelopeTokens = Math.max(0, measurement.totalTokens - measurement.surfaceTokens);
|
|
134
|
-
const planFor = (markerTokens) =>
|
|
135
|
-
planTrim({
|
|
136
|
-
nodes,
|
|
137
|
-
envelopeTokens,
|
|
138
|
-
budget,
|
|
139
|
-
markerCost: markerTokens + config.markerSlackTokens,
|
|
140
|
-
retainTokens,
|
|
141
|
-
minTailTokens: config.minTailTokens,
|
|
142
|
-
protectHeadNodes: config.protectHeadNodes,
|
|
143
|
-
allowTailTrim: config.allowTailTrim,
|
|
144
|
-
isBalancedBefore: (seq) => toolPairingBalancedBefore(session, seq),
|
|
145
|
-
isBalancedAfter: (seq) => toolPairingBalancedAfter(session, seq)
|
|
146
|
-
});
|
|
147
|
-
let markerTokens = ctx.tokenMeter.estimateMessage(provisionalMarker());
|
|
148
|
-
let plan = planFor(markerTokens);
|
|
149
|
-
if (plan.kind !== 'span') return describeNonSpan(plan, label);
|
|
150
|
-
let marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
|
|
151
|
-
const finalMarkerTokens = ctx.tokenMeter.estimateMessage(marker);
|
|
152
|
-
if (finalMarkerTokens > markerTokens) {
|
|
153
|
-
// The marker now carries real numbers; re-plan once so its own price is exact.
|
|
154
|
-
plan = planFor(finalMarkerTokens);
|
|
155
|
-
if (plan.kind !== 'span') return describeNonSpan(plan, label);
|
|
156
|
-
marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
|
|
157
|
-
markerTokens = finalMarkerTokens;
|
|
158
|
-
}
|
|
159
|
-
if (parsed.check) return { kind: 'success', text: preview(plan, label, markerTokens) };
|
|
160
|
-
const replacement = applyTrim(session, plan, marker, replaceKeys());
|
|
161
|
-
const after = ctx.tokenMeter.measure(session, header);
|
|
162
|
-
return { kind: 'success', text: report(plan, after, label, markerTokens), sourceEventSeq: replacement.seq };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Refuse to rewrite a surface while a compaction bracket is open. */
|
|
166
|
-
function assertNoOpenCompaction(session) {
|
|
167
|
-
for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
|
|
168
|
-
const event = session.eventAt(seq);
|
|
169
|
-
if (event === undefined) continue;
|
|
170
|
-
if (event.type === 'compaction/end') return;
|
|
171
|
-
// A seed boundary proves any earlier unmatched start belongs to a previous lifecycle.
|
|
172
|
-
if (event.type === 'session/end-seed') return;
|
|
173
|
-
if (event.type === 'compaction/start') {
|
|
174
|
-
throw new Error('a compaction is already in progress in this session; wait for it to finish, then retry');
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Render every non-span outcome as a final command result. */
|
|
180
|
-
function describeNonSpan(plan, label) {
|
|
181
|
-
switch (plan.kind) {
|
|
182
|
-
case 'fits':
|
|
183
|
-
return {
|
|
184
|
-
kind: 'success',
|
|
185
|
-
text: `Already within budget: ~${plan.totalTokens} / ${plan.budget} tokens for ${label}. Nothing to trim.`
|
|
186
|
-
};
|
|
187
|
-
case 'envelope':
|
|
188
|
-
return {
|
|
189
|
-
kind: 'error',
|
|
190
|
-
text: [
|
|
191
|
-
`Fixed request overhead alone (~${plan.envelopeTokens} tokens of tool schemas and other non-surface request data) exceeds the ${plan.budget}-token budget for ${label}.`,
|
|
192
|
-
'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.'
|
|
193
|
-
].join('\n')
|
|
194
|
-
};
|
|
195
|
-
case 'no-span':
|
|
196
|
-
return {
|
|
197
|
-
kind: 'error',
|
|
198
|
-
text: `Nothing safely trimmable for ${label}: ${plan.reason}, or no tool-pairing balanced cut exists.`
|
|
199
|
-
};
|
|
200
|
-
case 'insufficient':
|
|
201
|
-
return {
|
|
202
|
-
kind: 'error',
|
|
203
|
-
text: [
|
|
204
|
-
`Cannot free enough for ${label}: the largest balanced span frees ~${plan.maxFreeable} of the ~${plan.need} tokens needed.`,
|
|
205
|
-
`Protected content: task statement ~${plan.protectedHeadTokens} tokens, recent tail ~${plan.protectedTailTokens} tokens (retain target ~${plan.retainTokens})${plan.hasBarrier ? ', plus the system prompt, which is never trimmed' : ''}.`,
|
|
206
|
-
'Try /compact (it summarizes instead of dropping), a larger window, or /trim with an explicit budget after another reduction.'
|
|
207
|
-
].join('\n')
|
|
208
|
-
};
|
|
209
|
-
default:
|
|
210
|
-
return { kind: 'error', text: `Trim could not plan a reduction (${String(plan.kind)}).` };
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** Render a dry run. */
|
|
215
|
-
function preview(plan, label, markerTokens) {
|
|
216
|
-
return [
|
|
217
|
-
`Would trim ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
|
|
218
|
-
`Request size: ~${plan.totalTokens} → ~${plan.projectedTotal} tokens (target ${plan.budget}). Nothing was changed.`
|
|
219
|
-
].join('\n');
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Render a committed trim against the re-measured request. */
|
|
223
|
-
function report(plan, after, label, markerTokens) {
|
|
224
|
-
const lines = [
|
|
225
|
-
`Trimmed ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
|
|
226
|
-
`Request size: ~${plan.totalTokens} → ~${after.totalTokens} tokens (target ${plan.budget}).`
|
|
227
|
-
];
|
|
228
|
-
if (plan.relaxedRetention) {
|
|
229
|
-
lines.push(`Retention relaxed to ~${plan.retainTokens} tokens to reach the budget.`);
|
|
230
|
-
}
|
|
231
|
-
return lines.join('\n');
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Render a thrown value without trusting its string coercion. */
|
|
235
|
-
function describeError(error) {
|
|
236
|
-
try {
|
|
237
|
-
return error instanceof Error ? error.message : String(error);
|
|
238
|
-
} catch {
|
|
239
|
-
return '<unrenderable thrown value>';
|
|
240
|
-
}
|
|
241
|
-
}
|
package/lib/plan.js
CHANGED
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
* original request instead. Barriers are therefore untouchable and split the
|
|
18
18
|
* elidable space into regions, and `protectHeadNodes` counts only non-barrier
|
|
19
19
|
* nodes so it keeps protecting the task statement.
|
|
20
|
-
* - **The
|
|
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.
|
|
21
25
|
*
|
|
22
26
|
* Nothing here touches a session: the planner receives measured node prices and
|
|
23
27
|
* two balance predicates, so every branch is directly testable.
|
|
@@ -31,6 +35,8 @@
|
|
|
31
35
|
* @property {number} heuristicTokens - the token meter's heuristic price for it.
|
|
32
36
|
* @property {boolean} [barrier] - true for a node that may never be elided and
|
|
33
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.
|
|
34
40
|
*/
|
|
35
41
|
|
|
36
42
|
/**
|
|
@@ -69,20 +75,21 @@ export function planTrim(input) {
|
|
|
69
75
|
const need = common.totalTokens - input.budget;
|
|
70
76
|
const configuredRetention = input.retainTokens;
|
|
71
77
|
let weakest = null;
|
|
72
|
-
for (const
|
|
73
|
-
const attempt = attemptSpan({ ...input,
|
|
78
|
+
for (const configuration of attemptConfigurations(input, configuredRetention)) {
|
|
79
|
+
const attempt = attemptSpan({ ...input, ...configuration, need });
|
|
74
80
|
if (attempt.kind === 'span') {
|
|
75
81
|
return {
|
|
76
82
|
kind: 'span',
|
|
77
83
|
...common,
|
|
78
84
|
...attempt,
|
|
79
85
|
need,
|
|
80
|
-
retainTokens,
|
|
81
|
-
relaxedRetention: retainTokens < configuredRetention,
|
|
86
|
+
retainTokens: configuration.retainTokens,
|
|
87
|
+
relaxedRetention: configuration.retainTokens < configuredRetention,
|
|
88
|
+
reachedFinalNode: configuration.allowFinalNode === true,
|
|
82
89
|
projectedTotal: common.totalTokens - attempt.freedTokens
|
|
83
90
|
};
|
|
84
91
|
}
|
|
85
|
-
if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = { ...attempt, retainTokens };
|
|
92
|
+
if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = { ...attempt, retainTokens: configuration.retainTokens };
|
|
86
93
|
}
|
|
87
94
|
return {
|
|
88
95
|
kind: 'insufficient',
|
|
@@ -91,10 +98,44 @@ export function planTrim(input) {
|
|
|
91
98
|
maxFreeable: weakest?.maxFreeable ?? 0,
|
|
92
99
|
protectedHeadTokens: protectedHeadTokens(input.nodes, input.protectHeadNodes),
|
|
93
100
|
protectedTailTokens: weakest?.protectedTailTokens ?? 0,
|
|
101
|
+
protectedUserTokens: weakest?.protectedUserTokens ?? 0,
|
|
94
102
|
retainTokens: configuredRetention
|
|
95
103
|
};
|
|
96
104
|
}
|
|
97
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
|
+
|
|
98
139
|
/**
|
|
99
140
|
* Try one retention budget, returning the smallest oldest-anchored balanced span
|
|
100
141
|
* that frees at least `need` tokens.
|
|
@@ -104,17 +145,25 @@ export function planTrim(input) {
|
|
|
104
145
|
function attemptSpan(input) {
|
|
105
146
|
const nodes = input.nodes;
|
|
106
147
|
const tailStart = tailStartIndex(nodes, input.retainTokens);
|
|
107
|
-
|
|
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);
|
|
108
156
|
const shortfall = (maxFreeable) => ({
|
|
109
157
|
kind: 'shortfall',
|
|
110
158
|
maxFreeable,
|
|
111
159
|
protectedTailTokens: tailTokens(nodes, tailStart),
|
|
112
|
-
protectedHeadTokens: protectedHeadTokens(nodes, input.protectHeadNodes)
|
|
160
|
+
protectedHeadTokens: protectedHeadTokens(nodes, input.protectHeadNodes),
|
|
161
|
+
protectedUserTokens: newestUserIndex === -1 ? 0 : nodes[newestUserIndex].heuristicTokens
|
|
113
162
|
});
|
|
114
163
|
const headEnd = protectedHeadEnd(nodes, input.protectHeadNodes);
|
|
115
164
|
if (lastElidable < headEnd) return shortfall(0);
|
|
116
165
|
let weakest = null;
|
|
117
|
-
for (const region of elidableRegions(nodes, headEnd, lastElidable)) {
|
|
166
|
+
for (const region of elidableRegions(nodes, headEnd, lastElidable, newestUserIndex)) {
|
|
118
167
|
const attempt = attemptRegion(nodes, region, input);
|
|
119
168
|
if (attempt === null) continue;
|
|
120
169
|
if (attempt.kind === 'span') return attempt;
|
|
@@ -172,12 +221,26 @@ function protectedHeadEnd(nodes, protectHeadNodes) {
|
|
|
172
221
|
return nodes.length;
|
|
173
222
|
}
|
|
174
223
|
|
|
175
|
-
/**
|
|
176
|
-
function
|
|
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) {
|
|
177
240
|
const regions = [];
|
|
178
241
|
let start = null;
|
|
179
242
|
for (let index = from; index <= to; index += 1) {
|
|
180
|
-
if (nodes[index].barrier === true) {
|
|
243
|
+
if (nodes[index].barrier === true || index === extraBarrier) {
|
|
181
244
|
if (start !== null) regions.push({ start, end: index - 1 });
|
|
182
245
|
start = null;
|
|
183
246
|
continue;
|
|
@@ -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
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
}
|
|
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/target.js
CHANGED
|
@@ -40,8 +40,10 @@ export function latestRoute(session) {
|
|
|
40
40
|
* @returns `{ provider, model, contextWindow, label }`.
|
|
41
41
|
* @throws when no route can be determined, the route is unknown, or its adapter declares no window.
|
|
42
42
|
*/
|
|
43
|
-
export async function resolveTarget(ctx, agent, requested, signal) {
|
|
44
|
-
const
|
|
43
|
+
export async function resolveTarget(ctx, agent, requested, signal, options = {}) {
|
|
44
|
+
const primary =
|
|
45
|
+
options.routedOnly === true ? routedTarget(agent.session) ?? latestRoute(agent.session) : latestRoute(agent.session);
|
|
46
|
+
const route = requested ?? primary ?? agentRoute(agent);
|
|
45
47
|
if (route === undefined) {
|
|
46
48
|
throw new Error('cannot determine the target model: select a model in this session first, or pass an explicit budget (e.g. /trim 32k)');
|
|
47
49
|
}
|
|
@@ -82,6 +84,20 @@ export function targetHeader(session, target) {
|
|
|
82
84
|
});
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The route the last durable request was actually built for: the one that just
|
|
89
|
+
* overflowed during automatic recovery.
|
|
90
|
+
* @param session - session whose log is read.
|
|
91
|
+
* @returns `{ provider, model, source }`, or undefined when nothing was routed yet.
|
|
92
|
+
*/
|
|
93
|
+
export function routedTarget(session) {
|
|
94
|
+
const config = session.requestHeader()?.config;
|
|
95
|
+
if (config === undefined) return undefined;
|
|
96
|
+
if (typeof config.provider !== 'string' || config.provider.length === 0) return undefined;
|
|
97
|
+
if (typeof config.model !== 'string' || config.model.length === 0) return undefined;
|
|
98
|
+
return { provider: config.provider, model: config.model, source: 'request' };
|
|
99
|
+
}
|
|
100
|
+
|
|
85
101
|
/** Per-agent configured route, used only before any request was routed. */
|
|
86
102
|
function agentRoute(agent) {
|
|
87
103
|
const provider = agent.options?.provider;
|