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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One trim execution, shared by the `/trim` command and the automatic
|
|
3
|
+
* context-overflow path.
|
|
4
|
+
*
|
|
5
|
+
* Everything session-visible happens here: measure the current request under the
|
|
6
|
+
* target route, plan the oldest balanced span, and apply it as two synchronous
|
|
7
|
+
* appends. The caller owns only *when* to invoke it (`/trim` inside an idle-agent
|
|
8
|
+
* reservation, the automatic path inside a failing step) and how to render the
|
|
9
|
+
* outcome.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-command-context-trim/trim-session
|
|
12
|
+
*/
|
|
13
|
+
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction';
|
|
14
|
+
import { applyTrim, createMarkerMessage, provisionalMarker } from './apply.js';
|
|
15
|
+
import { budgetFor, retentionFor } from './config.js';
|
|
16
|
+
import { planTrim } from './plan.js';
|
|
17
|
+
import { shrinkOversizedToolResults } from './prune-first.js';
|
|
18
|
+
import { describeNonSpan, preview, report } from './render.js';
|
|
19
|
+
import { replaceKeys } from './session-compat.js';
|
|
20
|
+
import { resolveTarget, targetHeader } from './target.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {object} TrimRequest
|
|
24
|
+
* @property {object} agent - agent owning the session to trim.
|
|
25
|
+
* @property {AbortSignal} [signal] - cancellation for the whole execution.
|
|
26
|
+
* @property {{ provider: string, model: string }} [requestedRoute] - explicit target route.
|
|
27
|
+
* @property {number} [explicitBudget] - explicit token budget instead of a resolved window.
|
|
28
|
+
* @property {boolean} [check] - plan only, mutate nothing.
|
|
29
|
+
* @property {boolean} [routedOnly] - target the route the last durable request used.
|
|
30
|
+
* @property {number} [budgetCeiling] - hard upper bound on the target budget, used when a
|
|
31
|
+
* request of a known size has just been rejected and the declared window cannot be trusted.
|
|
32
|
+
* @property {boolean} [inPlaceFirst] - slim oversized tool results in place before planning a
|
|
33
|
+
* span. Only legal inside an open turn, so the automatic path sets it and `/trim` never does.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Execute one trim.
|
|
38
|
+
* @param ctx - plugin context (token meter, LLM service).
|
|
39
|
+
* @param config - resolved configuration.
|
|
40
|
+
* @param request - what to trim and how far.
|
|
41
|
+
* @returns `{ result, plan?, label?, before?, after?, replacement?, markerTokens? }`;
|
|
42
|
+
* `result` is a command result, and `replacement` is present only when the
|
|
43
|
+
* surface was actually rewritten.
|
|
44
|
+
*/
|
|
45
|
+
export async function executeTrim(ctx, config, request) {
|
|
46
|
+
const { agent, signal, requestedRoute, explicitBudget, check = false, routedOnly = false, budgetCeiling, inPlaceFirst = false } = request;
|
|
47
|
+
signal?.throwIfAborted?.();
|
|
48
|
+
const session = agent.session;
|
|
49
|
+
assertNoOpenCompaction(session);
|
|
50
|
+
const target =
|
|
51
|
+
explicitBudget === undefined ? await resolveTarget(ctx, agent, requestedRoute, signal, { routedOnly }) : undefined;
|
|
52
|
+
const windowBudget = explicitBudget ?? budgetFor(target.contextWindow, config);
|
|
53
|
+
const budget = budgetCeiling === undefined ? windowBudget : Math.min(windowBudget, budgetCeiling);
|
|
54
|
+
const baseLabel = target === undefined ? `an explicit ${budget}-token budget` : `${target.label} (window ${target.contextWindow})`;
|
|
55
|
+
const label = budget === windowBudget ? baseLabel : `${baseLabel}, capped at ${budget}`;
|
|
56
|
+
const header = target === undefined ? undefined : targetHeader(session, target);
|
|
57
|
+
let measurement = ctx.tokenMeter.measure(session, header);
|
|
58
|
+
// Cheap reduction first: a single oversized tool result is slimmed in place
|
|
59
|
+
// (keeping the node, its tool call and the prefix up to it) and a span is only
|
|
60
|
+
// elided when that is not enough.
|
|
61
|
+
let pruned;
|
|
62
|
+
if (inPlaceFirst) {
|
|
63
|
+
const shrunk = shrinkOversizedToolResults(ctx, session, config);
|
|
64
|
+
if (shrunk.pruned > 0) {
|
|
65
|
+
const afterPrune = ctx.tokenMeter.measure(session, header);
|
|
66
|
+
pruned = shrunk;
|
|
67
|
+
if (afterPrune.totalTokens <= budget) {
|
|
68
|
+
return {
|
|
69
|
+
result: {
|
|
70
|
+
kind: 'success',
|
|
71
|
+
text: `Slimmed ${shrunk.pruned} oversized tool result(s) in place for ${label}: ~${measurement.totalTokens} → ~${afterPrune.totalTokens} tokens (target ${budget}).`,
|
|
72
|
+
...(shrunk.replacementSeq === undefined ? {} : { sourceEventSeq: shrunk.replacementSeq })
|
|
73
|
+
},
|
|
74
|
+
label,
|
|
75
|
+
before: measurement,
|
|
76
|
+
after: afterPrune,
|
|
77
|
+
pruned: shrunk
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
measurement = afterPrune;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const nodes = measurement.nodes.map((node) => {
|
|
84
|
+
const type = session.eventAt(node.seq)?.type;
|
|
85
|
+
return {
|
|
86
|
+
seq: node.seq,
|
|
87
|
+
heuristicTokens: node.heuristicTokens,
|
|
88
|
+
// Harness 0.1.5+ carries the system prompt as surface node 0. It is never
|
|
89
|
+
// elidable, and no elided span may cross it: dropping it would strip the
|
|
90
|
+
// model's instructions, and letting it consume head protection would expose
|
|
91
|
+
// the user's original request instead.
|
|
92
|
+
...(type === 'system/message' ? { barrier: true } : {}),
|
|
93
|
+
// The newest human prompt is the anchor the planner must never elide.
|
|
94
|
+
...(type === 'user/message' ? { userMessage: true } : {})
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
if (nodes.length === 0) {
|
|
98
|
+
return { result: { kind: 'success', text: `Nothing to trim: ${session.id} has no model-visible messages yet.` } };
|
|
99
|
+
}
|
|
100
|
+
const notePrune = (result) =>
|
|
101
|
+
pruned === undefined
|
|
102
|
+
? result
|
|
103
|
+
: { ...result, text: `${result.text}\n(Note: ${pruned.pruned} oversized tool result(s) were already slimmed in place.)` };
|
|
104
|
+
// The retained tail scales with the capacity being fitted: the target window
|
|
105
|
+
// when one is known, otherwise the explicit budget itself.
|
|
106
|
+
const retainTokens = retentionFor(target?.contextWindow ?? budget, config);
|
|
107
|
+
const envelopeTokens = Math.max(0, measurement.totalTokens - measurement.surfaceTokens);
|
|
108
|
+
const planFor = (markerTokens) =>
|
|
109
|
+
planTrim({
|
|
110
|
+
nodes,
|
|
111
|
+
envelopeTokens,
|
|
112
|
+
budget,
|
|
113
|
+
markerCost: markerTokens + config.markerSlackTokens,
|
|
114
|
+
retainTokens,
|
|
115
|
+
minTailTokens: config.minTailTokens,
|
|
116
|
+
protectHeadNodes: config.protectHeadNodes,
|
|
117
|
+
allowTailTrim: config.allowTailTrim,
|
|
118
|
+
isBalancedBefore: (seq) => toolPairingBalancedBefore(session, seq),
|
|
119
|
+
isBalancedAfter: (seq) => toolPairingBalancedAfter(session, seq)
|
|
120
|
+
});
|
|
121
|
+
let markerTokens = ctx.tokenMeter.estimateMessage(provisionalMarker());
|
|
122
|
+
let plan = planFor(markerTokens);
|
|
123
|
+
if (plan.kind !== 'span') return { result: notePrune(describeNonSpan(plan, label)), plan, label, before: measurement, ...(pruned === undefined ? {} : { pruned }) };
|
|
124
|
+
let marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
|
|
125
|
+
const finalMarkerTokens = ctx.tokenMeter.estimateMessage(marker);
|
|
126
|
+
if (finalMarkerTokens > markerTokens) {
|
|
127
|
+
// The marker now carries real numbers; re-plan once so its own price is exact.
|
|
128
|
+
plan = planFor(finalMarkerTokens);
|
|
129
|
+
if (plan.kind !== 'span') return { result: notePrune(describeNonSpan(plan, label)), plan, label, before: measurement, ...(pruned === undefined ? {} : { pruned }) };
|
|
130
|
+
marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
|
|
131
|
+
markerTokens = finalMarkerTokens;
|
|
132
|
+
}
|
|
133
|
+
if (check) {
|
|
134
|
+
return { result: { kind: 'success', text: preview(plan, label, markerTokens) }, plan, label, before: measurement, ...(pruned === undefined ? {} : { pruned }) };
|
|
135
|
+
}
|
|
136
|
+
const replacement = applyTrim(session, plan, marker, replaceKeys());
|
|
137
|
+
const after = ctx.tokenMeter.measure(session, header);
|
|
138
|
+
return {
|
|
139
|
+
result: { kind: 'success', text: report(plan, after, label, markerTokens), sourceEventSeq: replacement.seq },
|
|
140
|
+
plan,
|
|
141
|
+
label,
|
|
142
|
+
before: measurement,
|
|
143
|
+
after,
|
|
144
|
+
markerTokens,
|
|
145
|
+
replacement,
|
|
146
|
+
...(pruned === undefined ? {} : { pruned })
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Refuse to rewrite a surface while a compaction bracket is open.
|
|
152
|
+
* @param session - session whose log is inspected.
|
|
153
|
+
* @throws when an unmatched `compaction/start` is open in the current lifecycle.
|
|
154
|
+
*/
|
|
155
|
+
export function assertNoOpenCompaction(session) {
|
|
156
|
+
for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
|
|
157
|
+
const event = session.eventAt(seq);
|
|
158
|
+
if (event === undefined) continue;
|
|
159
|
+
if (event.type === 'compaction/end') return;
|
|
160
|
+
// A seed boundary proves any earlier unmatched start belongs to a previous lifecycle.
|
|
161
|
+
if (event.type === 'session/end-seed') return;
|
|
162
|
+
if (event.type === 'compaction/start') {
|
|
163
|
+
throw new Error('a compaction is already in progress in this session; wait for it to finish, then retry');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-command-context-trim",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Model-free /trim
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Model-free /trim for DeepSeek Harness — drop the oldest, least valuable span of context on demand (the `/trim` command) or automatically when a request hits the model's context wall, without any model call.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "snailium",
|
|
7
7
|
"type": "module",
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"scripts": {
|
|
28
28
|
"test": "node --test",
|
|
29
29
|
"link:harness": "bash scripts/dev-link-harness.sh",
|
|
30
|
-
"prepublishOnly": "npm test"
|
|
30
|
+
"prepublishOnly": "npm test",
|
|
31
|
+
"e2e:mock": "node scripts/mock-overflow-server.mjs"
|
|
31
32
|
},
|
|
32
33
|
"dsh": {
|
|
33
34
|
"bundle": {
|