@falling-ts/dsh-force-compact 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/LICENSE +21 -0
- package/README.cn.md +170 -0
- package/README.md +479 -0
- package/cordis.patch.yml +13 -0
- package/index.js +550 -0
- package/package.json +54 -0
- package/src/core/crashnet.js +215 -0
- package/src/core/log.js +346 -0
- package/src/core/pairing.js +188 -0
- package/src/core/policy.js +35 -0
- package/src/core/projected.js +139 -0
- package/src/core/settings.js +475 -0
- package/src/core/ui-signal.js +271 -0
- package/src/engine/backend.js +143 -0
- package/src/engine/builtin.js +1336 -0
- package/src/engine/checkpoint.js +206 -0
- package/src/engine/region.js +579 -0
- package/src/engine/summarizer.js +818 -0
- package/src/hooks/command.js +164 -0
- package/src/hooks/guard.js +706 -0
- package/src/hooks/idle.js +136 -0
- package/src/hooks/wire-rewrite.js +151 -0
- package/web/client.js +807 -0
- package/web/swish.css +188 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-force-compact's per-model-request guard — the "hook the core model
|
|
3
|
+
* request" half of the plugin.
|
|
4
|
+
*
|
|
5
|
+
* Instead of (or in addition to) the `session/flush` checkpoint, this guard
|
|
6
|
+
* runs at the official model-request seam so the decision is made **right
|
|
7
|
+
* before a model request is made**:
|
|
8
|
+
*
|
|
9
|
+
* - **`agent/request`** (a Waterfall around the frozen call configuration) —
|
|
10
|
+
* when the `disableThinking` setting is on, the returned `LlmCallConfig`
|
|
11
|
+
* carries `reasoningEffort: 'off'`, which the LLM adapter maps to
|
|
12
|
+
* `thinking: { type: 'disabled' }`. Every model request in this process is
|
|
13
|
+
* therefore sent with thinking/reasoning disabled.
|
|
14
|
+
* - **`agent/pre-step`** (a Waterfall before each model step) — reads the
|
|
15
|
+
* session's **projected context tokens** through the official
|
|
16
|
+
* `contextPressure` projection (`projectedTokens` — the exact figure the
|
|
17
|
+
* harness renders in the bottom-right corner, provider-anchored). When
|
|
18
|
+
* the reading is **>= `autoThresholdTokens`**, the guard rejects the proposed
|
|
19
|
+
* step (so the model request is NOT made) and instead retains the **latest
|
|
20
|
+
* `retainLatestTokens` of the conversation's tokens verbatim** while sending
|
|
21
|
+
* everything before that cutoff to the `compaction`
|
|
22
|
+
* service's `compactRegion` (read live via `ctx.get('compaction')`), which
|
|
23
|
+
* condenses the head history and lets the loop retry with a smaller context.
|
|
24
|
+
*
|
|
25
|
+
* Both settings are read **per request** through the synchronous
|
|
26
|
+
* `settings.get('falling-ts-force-compact')` so a `settings.yaml` edit is picked up on the
|
|
27
|
+
* next model request without a restart.
|
|
28
|
+
*
|
|
29
|
+
* @module @falling-ts/dsh-force-compact/request-guard
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readSettings, DEFAULTS } from '../core/settings.js'
|
|
33
|
+
import {
|
|
34
|
+
selectEarliestByTokens,
|
|
35
|
+
selectEarliestByMeasurements,
|
|
36
|
+
selectRetainingLatestTokens,
|
|
37
|
+
validateSurfaceRegionSafe,
|
|
38
|
+
} from '../engine/region.js'
|
|
39
|
+
import { resolveCompaction } from '../engine/backend.js'
|
|
40
|
+
import { publishCompressing, publishDone } from '../core/ui-signal.js'
|
|
41
|
+
import { guardFn, renderCrash, captureThrowSite, appendCrashLine as appendDiag } from '../core/crashnet.js'
|
|
42
|
+
import { getProjectedTokens } from '../core/projected.js'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Process-local "force compact now" records, one per session (keyed by
|
|
46
|
+
* `session.id`). Set by the `/force-compact` command handler when the agent is
|
|
47
|
+
* busy, and consumed (and cleared) by the `agent/pre-step` hook at the next
|
|
48
|
+
* model step. Each entry is a `{commandId}` object (P1 — carries the
|
|
49
|
+
* originating slash-command id so the pre-step consumer can thread it into the
|
|
50
|
+
* `compaction/*` bracket's `sourceCommandId` field). Survives across the
|
|
51
|
+
* agent's steps within the process without any durable state or timer.
|
|
52
|
+
* @type {Map<string, {commandId: string|undefined}>}
|
|
53
|
+
*/
|
|
54
|
+
const pendingForce = new Map()
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Queue a forced compaction for one session (the `/force-compact` command).
|
|
58
|
+
* When the agent is idle the command compacts directly; when it is busy it sets
|
|
59
|
+
* this flag so the next model step force-compacts instead of requesting the
|
|
60
|
+
* model.
|
|
61
|
+
* @param {string} sessionId
|
|
62
|
+
* @param {string|undefined} [commandId] the originating slash-command id (P1).
|
|
63
|
+
*/
|
|
64
|
+
/** Top-level entry — wrapped by the universal crash net. */
|
|
65
|
+
export const queueForceCompact = guardFn('guard.queueForceCompact', (sessionId, commandId) => {
|
|
66
|
+
if (sessionId !== undefined && sessionId !== null) {
|
|
67
|
+
pendingForce.set(sessionId, { commandId })
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Consume (and clear) any pending forced-compaction record for one session.
|
|
73
|
+
* @param {string} sessionId
|
|
74
|
+
* @returns {boolean} whether a force was pending and is now cleared.
|
|
75
|
+
*/
|
|
76
|
+
export const takeForceCompact = guardFn('guard.takeForceCompact', (sessionId) => {
|
|
77
|
+
const pending = pendingForce.get(sessionId)
|
|
78
|
+
if (pending) pendingForce.delete(sessionId)
|
|
79
|
+
return Boolean(pending)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read the pending forced-compaction record WITHOUT consuming it (peek). Used
|
|
84
|
+
* by the pre-step consumer to retrieve the `commandId` before clearing.
|
|
85
|
+
* @param {string} sessionId
|
|
86
|
+
* @returns {{commandId: string|undefined}|undefined}
|
|
87
|
+
*/
|
|
88
|
+
export const peekForceCompact = guardFn('guard.peekForceCompact', (sessionId) => {
|
|
89
|
+
return pendingForce.get(sessionId)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Estimate the total token count of the messages contained in a region span,
|
|
96
|
+
* using the `tokenMeter.estimateMessage` service when available and falling
|
|
97
|
+
* back to a 4-chars-per-token character heuristic. Mirrors the projection the
|
|
98
|
+
* builtin engine performs (`projectRegion`), but only to SUM sizes for the
|
|
99
|
+
* threshold-aware shrink gate — it builds no durable artifacts.
|
|
100
|
+
* @param {object|undefined} meter the `tokenMeter` service (may be undefined).
|
|
101
|
+
* @param {import('@deepseek-ai/dsh-session').Session} session
|
|
102
|
+
* @param {{start: number, end: number}} region the head-anchored span (inclusive seqs).
|
|
103
|
+
* @returns {number} the summed token estimate (0 when nothing measurable).
|
|
104
|
+
*/
|
|
105
|
+
/**
|
|
106
|
+
* Sum the meter's per-node prices for the surface nodes whose seq falls within
|
|
107
|
+
* the selected region's [start..end] seq window. Reusing the measurement's own
|
|
108
|
+
* node prices keeps the shrink gate's region figure on the SAME caliber as both
|
|
109
|
+
* the region selector and the gate's `totalTokens` (all fed by the one
|
|
110
|
+
* `measure()` snapshot). Falls back to re-pricing flat surface content via
|
|
111
|
+
* `meter.estimateMessage` / char-heuristic when no measurement is supplied.
|
|
112
|
+
*/
|
|
113
|
+
function estimateRegionTokens(meter, session, region, measurement) {
|
|
114
|
+
// PREFERRED: when a `measure()` snapshot is available, sum the node prices for
|
|
115
|
+
// the seq window directly — same pricer, same total caliber as the selector.
|
|
116
|
+
if (measurement !== undefined && Array.isArray(measurement.nodes)) {
|
|
117
|
+
const lo = Math.min(region.start, region.end)
|
|
118
|
+
const hi = Math.max(region.start, region.end)
|
|
119
|
+
let tokens = 0
|
|
120
|
+
for (const node of measurement.nodes) {
|
|
121
|
+
const n = Number(node.seq)
|
|
122
|
+
if (Number.isFinite(n) && n >= lo && n <= hi) {
|
|
123
|
+
const t = Number(node.tokens)
|
|
124
|
+
if (Number.isFinite(t) && t > 0) tokens += t
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return tokens
|
|
128
|
+
}
|
|
129
|
+
// LEGACY: no measurement — price the region's surface seqs manually. Malformed
|
|
130
|
+
// shapes degrade to whatever IS measurable (often 0) rather than throwing —
|
|
131
|
+
// feeds a shrink gate, never a correctness path.
|
|
132
|
+
const surfaceNodes = (session && session.surface && Array.isArray(session.surface.nodes)) ? session.surface.nodes : []
|
|
133
|
+
const nodes = [...surfaceNodes]
|
|
134
|
+
const firstIdx = nodes.indexOf(region.start)
|
|
135
|
+
const lastIdx = nodes.lastIndexOf(region.end)
|
|
136
|
+
const segment = (firstIdx >= 0 && lastIdx >= firstIdx)
|
|
137
|
+
? nodes.slice(firstIdx, lastIdx + 1)
|
|
138
|
+
: []
|
|
139
|
+
const events = (session && Array.isArray(session.events)) ? session.events : []
|
|
140
|
+
let tokens = 0
|
|
141
|
+
const useMeter = meter !== undefined && typeof meter.estimateMessage === 'function'
|
|
142
|
+
for (const seq of segment) {
|
|
143
|
+
const event = events[seq]
|
|
144
|
+
if (event === undefined || event === null || typeof event !== 'object') continue
|
|
145
|
+
const data = (event.data && typeof event.data === 'object') ? event.data : {}
|
|
146
|
+
let content
|
|
147
|
+
if (event.type === 'user/message') content = data.content
|
|
148
|
+
else if (event.type === 'assistant/message') content = (data.message && data.message.content !== undefined) ? data.message.content : undefined
|
|
149
|
+
else if (event.type === 'tool/result') content = (data.message && data.message.content !== undefined) ? data.message.content : undefined
|
|
150
|
+
if (content === undefined || content === null) continue
|
|
151
|
+
if (useMeter) {
|
|
152
|
+
try {
|
|
153
|
+
tokens += meter.estimateMessage({ role: 'user', content })
|
|
154
|
+
} catch {
|
|
155
|
+
/* estimator hiccup — ignore this block */
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
let chars = 0
|
|
159
|
+
const blocks = Array.isArray(content) ? content : []
|
|
160
|
+
for (const block of blocks) if (block && typeof block === 'object' && typeof block.text === 'string') chars += block.text.length
|
|
161
|
+
tokens += Math.ceil(chars / 4)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return tokens
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Compact a session's head so that the latest `retainLatestTokens` of the
|
|
169
|
+
* surface remains verbatim, sending the remainder to a single summarizer call
|
|
170
|
+
* via the `compaction` service's `compactRegion(start, end, agent, signal)`.
|
|
171
|
+
* Measures the session's total context tokens (via `tokenMeter` or a
|
|
172
|
+
* character-based fallback), then delegates the durable mutation.
|
|
173
|
+
*
|
|
174
|
+
* Selection prefers the meter's own per-node prices (when a `measure()` snapshot
|
|
175
|
+
* is available): starting FROM THE LATEST surface node, ACCUMULATE node tokens
|
|
176
|
+
* BACKWARD until the sum REACHES OR EXCEEDS `retainLatestTokens`; the cutoff
|
|
177
|
+
* splits the surface into the head SPAN TO COMPACT and the RETAINED TAIL
|
|
178
|
+
* (verbatim). Everything before the cut (plus the snap-to-nearest-preceding-
|
|
179
|
+
* tool-pairing-balanced-boundary adjustment — the official pairing ledger,
|
|
180
|
+
* a strict superset of `user/message` boundaries) is sent to the summarizer
|
|
181
|
+
* AS ONE BATCH — the original span's entries become shadowed/skipped in
|
|
182
|
+
* derived history. Before spending the summarization round-trip, the selected
|
|
183
|
+
* span passes TWO official safety gates (both ported from `compaction-basic`):
|
|
184
|
+
* a SURFACE-CONSISTENCY cross-check (the meter's priced snapshot must align
|
|
185
|
+
* node-for-node with the CURRENT `session.surface.nodes`; a concurrent
|
|
186
|
+
* modification between measure and selection aborts this attempt) and the
|
|
187
|
+
* `validateSurfaceRegion` DOUBLE-BALANCE gate (both bounds must sit on
|
|
188
|
+
* tool-pairing balanced cuts — a candidate that would split a step's
|
|
189
|
+
* tool-call/result pair is refused here, logged, and skipped).
|
|
190
|
+
*
|
|
191
|
+
* Legacy `selectEarliestByTokens` is used only when no measurement snapshot is
|
|
192
|
+
* available (tokenMeter absent): it estimates total tokens from char-count and
|
|
193
|
+
* picks a head-aligned prefix under the same `retainLatestTokens` budget (with
|
|
194
|
+
* an implicit total assumption that fits the legacy behavior).
|
|
195
|
+
*
|
|
196
|
+
* Never throws: all failures resolve `false` so the caller's model request
|
|
197
|
+
* proceeds unimpeded.
|
|
198
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
199
|
+
* @param {import('@deepseek-ai/dsh-agent').Agent} agent
|
|
200
|
+
* @param {AbortSignal|undefined} signal the current turn's signal (forwarded to compaction).
|
|
201
|
+
* @param {string|undefined} mode the `compactionMode` setting (passed by the caller); undefined re-reads live.
|
|
202
|
+
* @param {string|undefined} [sourceCommandId] P1 — the originating slash-command id threaded into the `compaction/*` bracket.
|
|
203
|
+
* @returns {Promise<boolean>} whether a compaction was committed.
|
|
204
|
+
*/
|
|
205
|
+
async function compactRetainingLatest(ctx, agent, signal, mode, sourceCommandId) {
|
|
206
|
+
// SAFETY ENVELOPE: this function's CONTRACT is to resolve `false` (let the
|
|
207
|
+
// request proceed) on ANY failure — a missing service, a missing span, a
|
|
208
|
+
// throwing `tokenMeter.measure`, a rejecting backend call, or even a
|
|
209
|
+
// malformed `agent` shape. None of those may propagate into the `agent/pre-step`
|
|
210
|
+
// waterfall, where an uncaught throw would surface as a stalled/aborted step
|
|
211
|
+
// (the "every request pauses" symptom). The actual logic lives in the inner
|
|
212
|
+
// closure; any exception anywhere inside resolves `false`.
|
|
213
|
+
try {
|
|
214
|
+
return await __compactRetainingLatestBody(ctx, agent, signal, mode, sourceCommandId)
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const message = error instanceof Error ? (error.stack || error.message) : String(error)
|
|
217
|
+
const sid = (agent && agent.session && agent.session.id) ? agent.session.id : '?'
|
|
218
|
+
ctx.logger.warn(`[force-compact] ${sid}: compactRetainingLatest degraded to false (letting the request proceed) — ${message}`)
|
|
219
|
+
return false
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Body of {@link compactRetainingLatest}; wrapped by its safe envelope. */
|
|
224
|
+
async function __compactRetainingLatestBody(ctx, agent, signal, mode, sourceCommandId) {
|
|
225
|
+
const settings = (await readSettings(ctx)) ?? DEFAULTS
|
|
226
|
+
const session = agent.session
|
|
227
|
+
if (session === undefined || session === null) return false
|
|
228
|
+
// Locate a usable compaction backend: the OFFICIAL `compaction` service
|
|
229
|
+
// (preferred when reachable) OR this plugin's OWN builtin engine (the
|
|
230
|
+
// fallback when the service is realm-isolated away — e.g. standard preset).
|
|
231
|
+
// Both backends expose the SAME `{ compactNow, compactRegion, kind }` shape
|
|
232
|
+
// so the call site below is agnostic to which one served the request.
|
|
233
|
+
const backend = await resolveCompaction(ctx, agent, mode)
|
|
234
|
+
if (backend === undefined || typeof backend.compactRegion !== 'function') {
|
|
235
|
+
const effMode = (mode !== undefined ? mode : settings.compactionMode)
|
|
236
|
+
ctx.logger.warn(
|
|
237
|
+
`[force-compact] ${session.id}: NO compaction backend available (official service unreachable AND builtin engine ` +
|
|
238
|
+
`either disabled via \`builtinEnabled=false\` or lacking prerequisites: llm.service/stream or agent.session). ` +
|
|
239
|
+
`No compaction performed. If you want the builtin fallback, ensure \`builtinEnabled=true\` in the ` +
|
|
240
|
+
`\`falling-ts-force-compact\` namespace and that the \`llm\` service is mounted.`
|
|
241
|
+
)
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
// Pressure basis — PROJECTED TOKENS (single definition with the harness UI).
|
|
245
|
+
// Reads the SAME `projectedTokens` the harness renders in the bottom-right
|
|
246
|
+
// corner (provider-anchored sample + surface movement since the sample), so
|
|
247
|
+
// the plugin's arithmetic never drifts from what the user sees.
|
|
248
|
+
//
|
|
249
|
+
// Historical note: this path previously keyed off
|
|
250
|
+
// `tokenMeter.measure().surfaceTokens` — the UNANCHORED meter-node sum —
|
|
251
|
+
// rejecting `totalTokens` because its usage baseline inflates and resets
|
|
252
|
+
// after each compaction. `projectedTokens` keeps a provider anchor while
|
|
253
|
+
// staying responsive to surface churn (compactions drop the figure the
|
|
254
|
+
// moment a span is shadowed), so the original objection no longer applies.
|
|
255
|
+
// When the reading is unavailable (fresh session with no usage sample yet,
|
|
256
|
+
// or a trimmed composition without the projection registry) we fall back to
|
|
257
|
+
// `surfaceTokens` — the closest same-caliber substitute — before giving up
|
|
258
|
+
// to the char estimator.
|
|
259
|
+
let totalTokens = getProjectedTokens(ctx, session)
|
|
260
|
+
// Measurement snapshot used for NODE-BY-NODE region selection: prefer the
|
|
261
|
+
// same-caliber `measure()` snapshot when a projection registry is not
|
|
262
|
+
// driving the basis (or is unavailable), otherwise reuse the meter
|
|
263
|
+
// snapshot for a consistent caliber across the selection path.
|
|
264
|
+
const meter = ctx.get('tokenMeter')
|
|
265
|
+
let measurement
|
|
266
|
+
if (meter !== undefined && typeof meter.measure === 'function') {
|
|
267
|
+
try {
|
|
268
|
+
const measured = meter.measure(session)
|
|
269
|
+
if (measured !== undefined && measured !== null) {
|
|
270
|
+
measurement = measured
|
|
271
|
+
if (totalTokens === undefined) {
|
|
272
|
+
totalTokens = (Number.isFinite(measured.surfaceTokens) && measured.surfaceTokens > 0)
|
|
273
|
+
? measured.surfaceTokens
|
|
274
|
+
: undefined
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
// leave `totalTokens` as-is (possibly already set from the projection)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Region selection: PREFER the same-caliber meter-node selector (prices each
|
|
282
|
+
// candidate from the very `measure()` snapshot (when that snapshot backs
|
|
283
|
+
// `totalTokens`) — so the budget is always reachable and the boundary
|
|
284
|
+
// well-defined). When the basis is the projection-derived `projectedTokens`,
|
|
285
|
+
// the snapshot is used purely for node-by-node pricing, decoupled from the
|
|
286
|
+
// scalar basis. The
|
|
287
|
+
// `maxRegionNodes` cap CLAMPS an oversized 0.ratio head-span down to the
|
|
288
|
+
// largest serviceable head-aligned prefix so the builtin engine's replay cap
|
|
289
|
+
// is never tripped and a region is ALWAYS committable on a threshold trip.
|
|
290
|
+
// Fall back to the legacy char-heuristic variant only when no measurement
|
|
291
|
+
// snapshot is available (tokenMeter absent). See the selectors' docs.
|
|
292
|
+
const maxRegionNodes = (settings.maxRegionNodes !== undefined && Number.isFinite(Number(settings.maxRegionNodes)))
|
|
293
|
+
? Number(settings.maxRegionNodes)
|
|
294
|
+
: undefined
|
|
295
|
+
// Prefer the tail-retaining selector when a measurement snapshot exists: it
|
|
296
|
+
// walks the node prices backward from the newest entry accumulating tokens
|
|
297
|
+
// until `>= retainLatestTokens`, snapping the cutoff to a preceding
|
|
298
|
+
// `user/message` boundary. This is exactly the "keep latest N tokens
|
|
299
|
+
// verbatim, send everything older in one batch to the LLM" semantic the
|
|
300
|
+
// user-facing `retainLatestTokens` knob promises.
|
|
301
|
+
//
|
|
302
|
+
// FALLBACK (legacy `selectEarliestByTokens`): when no measurement snapshot
|
|
303
|
+
// is available (tokenMeter absent), use the char-heuristic variant — it
|
|
304
|
+
// prices from `estimateSessionTokens` and applies the same `maxRegionNodes`
|
|
305
|
+
// clamp for the same bounded-region guarantee.
|
|
306
|
+
const region = (measurement !== undefined)
|
|
307
|
+
? selectRetainingLatestTokens(session, settings.retainLatestTokens, measurement)
|
|
308
|
+
: (() => {
|
|
309
|
+
// DEGENERATE FALLBACK (tokenMeter absent): express the retention
|
|
310
|
+
// semantic via the char-estimated total. The head to compact is at
|
|
311
|
+
// most (totalEstimated − retainLatestTokens); pass THAT absolute
|
|
312
|
+
// head-budget to the legacy selector, which walks from the head
|
|
313
|
+
// accumulating until the budget is consumed. When the retention
|
|
314
|
+
// budget exceeds the estimated total (tiny session), the head budget
|
|
315
|
+
// clamps to 0 and the selector trivially returns null — no compaction.
|
|
316
|
+
if (typeof totalTokens !== 'number' || !Number.isFinite(totalTokens) || totalTokens <= 0) return null
|
|
317
|
+
const headBudget = Math.max(0, Math.round(totalTokens - settings.retainLatestTokens))
|
|
318
|
+
if (headBudget <= 0) return null
|
|
319
|
+
return selectEarliestByTokens(session, headBudget, maxRegionNodes)
|
|
320
|
+
})()
|
|
321
|
+
if (region === null) {
|
|
322
|
+
ctx.logger.debug(`[force-compact] ${session.id}: no region to compact retaining ~${settings.retainLatestTokens} latest tokens (basis=${totalTokens == null ? 'unknown(fallback est)' : totalTokens}${measurement !== undefined ? `, surface nodes=${measurement.nodes?.length}, surfaceTokens=${typeof measurement.surfaceTokens === 'number' ? measurement.surfaceTokens : '?'}` : ''})`)
|
|
323
|
+
return false
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- Surface-consistency CROSS-CHECK (ported from the official
|
|
327
|
+
// `compaction-basic` `prepareCompaction`) -------------------------------
|
|
328
|
+
// The meter's priced snapshot MUST align position-for-position with the
|
|
329
|
+
// session's current surface nodes. When a concurrent modification landed a
|
|
330
|
+
// node between the `measure()` above and selection completion, the two
|
|
331
|
+
// disagree; proceeding would price a STALE span. Refuse the compaction
|
|
332
|
+
// attempt entirely (next step retries on a fresh snapshot) rather than pay
|
|
333
|
+
// for a summarization of the wrong bytes.
|
|
334
|
+
// NOTE: `measurement.nodes` entries are OBJECTS shaped `{ seq, tokens }`
|
|
335
|
+
// (per-node pricing), whereas `session.surface.nodes` is the bare SEQUENCE
|
|
336
|
+
// of surface-node seqs. Compare BY EXTRACTED SEQ, element for element — the
|
|
337
|
+
// meter snapshot is taken microseconds earlier, so a concurrent append or
|
|
338
|
+
// replace landing in between shows up here as either a length difference or
|
|
339
|
+
// a seq divergence at some position. (A naive `element !== surfaceNodes[i]`
|
|
340
|
+
// comparison is WRONG here: it compares an object to a number and ALWAYS
|
|
341
|
+
// diverges, refusing every single compaction — the live-observed symptom
|
|
342
|
+
// "priced=49 vs current=49 nodes" yet REFUSED.)
|
|
343
|
+
const surfaceNodes = (session.surface && Array.isArray(session.surface.nodes)) ? session.surface.nodes : []
|
|
344
|
+
const pricedNodes = (measurement !== undefined && Array.isArray(measurement.nodes)) ? measurement.nodes : null
|
|
345
|
+
const misaligned = pricedNodes.length !== surfaceNodes.length
|
|
346
|
+
|| pricedNodes.some((node, index) => node === null || typeof node !== 'object'
|
|
347
|
+
|| typeof node.seq !== 'number' || node.seq !== surfaceNodes[index])
|
|
348
|
+
if (pricedNodes !== null && misaligned) {
|
|
349
|
+
ctx.logger.debug(
|
|
350
|
+
`[force-compact] ${session.id}: token-meter surface does not match the current session surface ` +
|
|
351
|
+
`(priced=${pricedNodes.length} vs current=${surfaceNodes.length} nodes) — REFUSING this compaction ` +
|
|
352
|
+
`attempt rather than summarize a stale span; retrying on the next step.`
|
|
353
|
+
)
|
|
354
|
+
return false
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- Official PAIRING BOUNDARY GATE (ported from the official
|
|
358
|
+
// `validateSurfaceRegion`) ----------------------------------------------
|
|
359
|
+
// Before spending a summarization round-trip, verify BOTH bounds are
|
|
360
|
+
// tool-pairing balanced on the CURRENT surface (the precise per-event
|
|
361
|
+
// ledger, not an assumption about the selection having done its job). A
|
|
362
|
+
// candidate that would split a step's tool-call/result pair is refused
|
|
363
|
+
// HERE (fail-loud, logged) — the session core's own replace validation
|
|
364
|
+
// remains the last line of defense behind this gate.
|
|
365
|
+
const validated = validateSurfaceRegionSafe(session, region.start, region.end)
|
|
366
|
+
if (validated === null) {
|
|
367
|
+
ctx.logger.debug(
|
|
368
|
+
`[force-compact] ${session.id}: selected span seq ${region.start}..${region.end} FAILED the official ` +
|
|
369
|
+
`surface/balance validation (unknown bound, inverted index, or an unbalanced tool-pairing cut) — ` +
|
|
370
|
+
`REFUSING this compaction attempt; the session core's own replace validation remains the safety net.`
|
|
371
|
+
)
|
|
372
|
+
return false
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// THRESHOLD-AWARE SHRINK GATE (root fix for the low-threshold dead loop).
|
|
376
|
+
// Predict whether compacting this region can ACTUALLY pull the session below
|
|
377
|
+
// `autoThresholdTokens` before paying for a summarization LLM call. When the
|
|
378
|
+
// chosen region is too small relative to the total — i.e. even removing it
|
|
379
|
+
// WHOLE would leave total >= threshold — this compaction cannot achieve the
|
|
380
|
+
// goal, so attempting it just burns an LLM call and (because total hardly
|
|
381
|
+
// drops) re-arms the same gate on the next step: the "send 3 times, third
|
|
382
|
+
// wedges" storm. Skip early and let the request proceed.
|
|
383
|
+
//
|
|
384
|
+
// Only applied when we KNOW the total (tokenMeter available). With an unknown
|
|
385
|
+
// total there is no threshold comparison to make, so we proceed normally.
|
|
386
|
+
// This gate intentionally serves BOTH the auto-threshold path AND the
|
|
387
|
+
// explicit `/force-compact` path (both funnel here), so a command that
|
|
388
|
+
// cannot shrink below the threshold is likewise deferred rather than spammed.
|
|
389
|
+
if (totalTokens !== undefined && totalTokens >= settings.autoThresholdTokens) {
|
|
390
|
+
let regionTokens
|
|
391
|
+
try {
|
|
392
|
+
regionTokens = estimateRegionTokens(meter, session, region, measurement)
|
|
393
|
+
} catch {
|
|
394
|
+
regionTokens = 0 // a measurement failure means "skip the shrink gate"; the inner try/catch handles the eventual compaction.
|
|
395
|
+
}
|
|
396
|
+
// NOTE ON CAP-CLAMPING WITH TAIL RETENTION: unlike the legacy
|
|
397
|
+
// ratio-of-total selector (where a capped head-span had to be deliberately
|
|
398
|
+
// bypassed because committing it was the ONLY way to make headway), the
|
|
399
|
+
// new tail-retention semantic ALREADY bounds the retained side. When a
|
|
400
|
+
// measurement snapshot is present, `selectRetainingLatestTokens` returns a
|
|
401
|
+
// region whose head-span is AT MOST (windowSum − retainLatestTokens)
|
|
402
|
+
// tokens wide — inherently a bounded head. If THAT bound is still too big
|
|
403
|
+
// to cross the threshold, skipping is CORRECT here: retrying the SAME
|
|
404
|
+
// region next step changes nothing (nothing shrunk), so deferring avoids
|
|
405
|
+
// burning repeated summarization calls. We therefore DO NOT special-case
|
|
406
|
+
// a "capped head-span" branch — the math is simpler and correct.
|
|
407
|
+
if (typeof regionTokens === 'number' && regionTokens > 0 && totalTokens - regionTokens >= settings.autoThresholdTokens) {
|
|
408
|
+
ctx.logger.debug(
|
|
409
|
+
`[force-compact] ${session.id}: threshold-aware gate — retained-tail region (~${regionTokens} tokens; retains ~${settings.retainLatestTokens} latest tokens) `
|
|
410
|
+
+ `cannot pull total ~${totalTokens} below threshold ${settings.autoThresholdTokens} `
|
|
411
|
+
+ `(would still be ~${totalTokens - regionTokens}); SKIPPING compaction, letting the request proceed`
|
|
412
|
+
)
|
|
413
|
+
return false
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
ctx.logger.debug(
|
|
418
|
+
`[force-compact] ${session.id}: compacting head spanning seqs ${region?.start}..${region?.end} `
|
|
419
|
+
+ `while retaining the latest ~${settings.retainLatestTokens} tokens, via ${backend?.kind} backend (totalTokens=${totalTokens})`
|
|
420
|
+
+ ` | REGION-PICK budget=${settings.retainLatestTokens} `
|
|
421
|
+
+ `crossingAccBefore=${region.crossingAccBefore} `
|
|
422
|
+
+ `crossingNodeSize=${region.crossingNodeSize} `
|
|
423
|
+
+ `crossingAccAfter=${region.crossingAccAfter} `
|
|
424
|
+
+ `boundaryKind=${region.boundaryKind ?? 'unknown'} `
|
|
425
|
+
+ `retainedTokens(after-boundary-snap)=${region.retainedTokens}`
|
|
426
|
+
)
|
|
427
|
+
try {
|
|
428
|
+
// LIVE UI SIGNAL — PIN RED "compressing" BEFORE the region compaction
|
|
429
|
+
// commits. This single site covers BOTH pre-step trigger paths (queued
|
|
430
|
+
// `/force-compact` flag and the auto token-threshold gate), since both
|
|
431
|
+
// funnel through `compactRetainingLatest`. Publishers swallow their own
|
|
432
|
+
// failures — the messenger can never affect whether the compaction itself
|
|
433
|
+
// commits.
|
|
434
|
+
await publishCompressing(ctx)
|
|
435
|
+
// P1 — forward `sourceCommandId` as the 5th positional arg (official
|
|
436
|
+
// `compactRegion(start, end, agent, signal, sourceCommandId)` and the
|
|
437
|
+
// builtin equivalent both absorb it).
|
|
438
|
+
const result = await backend.compactRegion(region.start, region.end, agent, signal, sourceCommandId)
|
|
439
|
+
if (result === undefined || result === null) {
|
|
440
|
+
ctx.logger.debug(`[force-compact] ${session.id}: retained-tail compaction committed nothing via ${backend?.kind}`)
|
|
441
|
+
return false
|
|
442
|
+
}
|
|
443
|
+
// COMMITTED — range shadowed + summary added.
|
|
444
|
+
// Pin GREEN "done"; the next model step's `llm/stream` watermark replaces
|
|
445
|
+
// it with a fresh random working pair shortly after (cadence < 3 s, no timer).
|
|
446
|
+
await publishDone(ctx)
|
|
447
|
+
ctx.logger.info(
|
|
448
|
+
`[force-compact] ${session.id}: retained-latest-${settings.retainLatestTokens}-tokens compaction (${backend?.kind}) `
|
|
449
|
+
+ `shadowed ${result.shadowedSeqs?.length ?? '?'} nodes (~${result.shadowedTokenCount ?? '?'} tokens) `
|
|
450
|
+
+ `spanning seqs ${region?.start}..${region?.end}`,
|
|
451
|
+
)
|
|
452
|
+
return true
|
|
453
|
+
} catch (error) {
|
|
454
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
455
|
+
ctx.logger.warn(`[force-compact] ${session.id}: retained-tail compaction via ${backend?.kind} FAILED — ${message}`)
|
|
456
|
+
return false
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* The `agent/pre-step` guard. A `/force-compact` command queued a force flag for
|
|
462
|
+
* this agent (`takeForceCompact`) → compact immediately, bypassing the token
|
|
463
|
+
* threshold. Otherwise, measure the session's total context tokens and, when they
|
|
464
|
+
* reach `autoThresholdTokens`, compact instead. A failed or no-safe-range
|
|
465
|
+
* compaction resolves to `false` (let the request proceed) rather than throwing.
|
|
466
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
467
|
+
* @param {import('@deepseek-ai/dsh-agent').Agent} agent
|
|
468
|
+
* @param {AbortSignal|undefined} signal the current turn's signal.
|
|
469
|
+
* @param {string|undefined} mode the `compactionMode` setting (passed by the caller); undefined re-reads live.
|
|
470
|
+
* @returns {Promise<boolean>} `true` when the caller should return `{ kind: 'reject' }`.
|
|
471
|
+
*/
|
|
472
|
+
// SAFETY ENVELOPE (pre-step gate): the CONTRACT is to resolve `false` (let
|
|
473
|
+
// the model request proceed) whenever ANYTHING goes wrong — missing/malformed
|
|
474
|
+
// `agent.session`, a rejecting `readSettings`, a throwing `tokenMeter.measure`,
|
|
475
|
+
// or a failing compaction. An uncaught throw HERE would surface as a broken
|
|
476
|
+
// `agent/pre-step` step (a stall), which is precisely the "every request
|
|
477
|
+
// pauses" symptom we are eliminating. So the entire body is contained; any
|
|
478
|
+
// anomaly logs and lets the request through.
|
|
479
|
+
// Additionally, the wrapper appends a UNIVERSAL-CRASH-NET diagnostic
|
|
480
|
+
// (message, thrownAt file:line:col, deepest plugin frame, nearest
|
|
481
|
+
// non-plugin frame, full call stack) to the durable crash log — belt-
|
|
482
|
+
// and-braces beyond the ctx.logger line above.
|
|
483
|
+
async function __forceCompactIfNeededEnvelope(ctx, agent, signal, mode) {
|
|
484
|
+
try {
|
|
485
|
+
return await __forceCompactIfNeededBody(ctx, agent, signal, mode)
|
|
486
|
+
} catch (error) {
|
|
487
|
+
const message = error instanceof Error ? (error.stack || error.message) : String(error)
|
|
488
|
+
ctx.logger.warn(`[force-compact] forceCompactIfNeeded degraded to false (letting the request proceed) — ${message}`)
|
|
489
|
+
// Crash-net side-channel: always-visible file entry even if ctx.logger
|
|
490
|
+
// is miswired. Swallows its own errors (never disturbs the degradation).
|
|
491
|
+
try {
|
|
492
|
+
const lines = renderCrash('guard.forceCompactIfNeeded', error, captureThrowSite())
|
|
493
|
+
for (const line of lines) appendDiag(line)
|
|
494
|
+
} catch (_netFailure) { /* never affect the request path */ }
|
|
495
|
+
return false
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export const forceCompactIfNeeded = guardFn('guard.forceCompactIfNeeded', __forceCompactIfNeededEnvelope)
|
|
500
|
+
|
|
501
|
+
/** Body of {@link forceCompactIfNeeded}; wrapped by its safe envelope. */
|
|
502
|
+
async function __forceCompactIfNeededBody(ctx, agent, signal, mode) {
|
|
503
|
+
const settings = (await readSettings(ctx)) ?? DEFAULTS
|
|
504
|
+
const session = (agent && typeof agent === 'object') ? agent.session : undefined
|
|
505
|
+
// No usable session object → nothing to gate; let the request proceed.
|
|
506
|
+
if (session === undefined || session === null || typeof session.id !== 'string') {
|
|
507
|
+
ctx.logger.debug(`[force-compact] forceCompactIfNeeded: agent.session unusable — threshold gate skipped, letting the request proceed`)
|
|
508
|
+
return false
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// A `/force-compact` command was issued for this agent while it was busy:
|
|
512
|
+
// compact now, regardless of the token threshold — retain the latest
|
|
513
|
+
// `retainLatestTokens` of the surface, compress the head in one batch.
|
|
514
|
+
// P1 — peek the pending record to recover the originating `commandId`
|
|
515
|
+
// BEFORE `takeForceCompact` clears it, so the `compaction/*` bracket can
|
|
516
|
+
// echo the same `sourceCommandId` the idle-manual path used.
|
|
517
|
+
const pendingRecord = peekForceCompact(session.id)
|
|
518
|
+
if (takeForceCompact(session.id)) {
|
|
519
|
+
const queuedCommandId = (pendingRecord && typeof pendingRecord.commandId === 'string' && pendingRecord.commandId.length > 0)
|
|
520
|
+
? pendingRecord.commandId
|
|
521
|
+
: undefined
|
|
522
|
+
ctx.logger.info(`[force-compact] ${session.id}: /force-compact queued; force-compacting the head (keeping the latest ~${settings.retainLatestTokens} tokens) immediately${queuedCommandId ? ` (commandId=${queuedCommandId})` : ''}`)
|
|
523
|
+
const committed = await compactRetainingLatest(ctx, agent, signal, mode, queuedCommandId)
|
|
524
|
+
ctx.logger.debug(`[force-compact] ${session.id}: /force-compact forced compaction ${committed ? 'COMMITTED' : 'did not commit'} — letting the request proceed`)
|
|
525
|
+
return committed
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// PROJECTED TOKENS — the authoritative pressure basis. Single definition
|
|
529
|
+
// with the harness UI: reads the SAME `projectedTokens` the harness renders
|
|
530
|
+
// in the bottom-right corner, so the plugin's threshold arithmetic never
|
|
531
|
+
// drifts from what the user sees.
|
|
532
|
+
//
|
|
533
|
+
// Caliber note (vs the previous `surfaceTokens` basis): the delta term in
|
|
534
|
+
// `projectedTokens` is estimated at the meter's fixed CHARS_PER_TOKEN=4
|
|
535
|
+
// density, so heavy-CJK / tool-JSON content is systematically undercounted
|
|
536
|
+
// compared to the unanchored node sum — a deliberate provider-anchor trade
|
|
537
|
+
// the meter prefers. Gate thresholds calibrated on the old basis may fire
|
|
538
|
+
// slightly less often; that is intended, not a regression.
|
|
539
|
+
const projectedTotal = getProjectedTokens(ctx, session)
|
|
540
|
+
const total = (typeof projectedTotal === 'number' && Number.isFinite(projectedTotal) && projectedTotal > 0)
|
|
541
|
+
? projectedTotal
|
|
542
|
+
: estimateSessionTokens(session)
|
|
543
|
+
// Meter snapshot retained for TWO purposes on the threshold branch: (a) the
|
|
544
|
+
// node-priced region selection fed to `compactRetainingLatest` below — keeps
|
|
545
|
+
// the official tool-pairing ledger alignment even when the THRESHOLD BASIS
|
|
546
|
+
// comes from the projection (scalar basis ≠ node-pricing source, and that
|
|
547
|
+
// split is deliberate); (b) the diagnostic facets logged on the rare
|
|
548
|
+
// threshold-hit branch. Defensive:
|
|
549
|
+
// `measure` might return undefined/a non-object or throw on a transient
|
|
550
|
+
// glitch — neither may propagate through a gate that decides whether to run
|
|
551
|
+
// a model step.
|
|
552
|
+
const meter = ctx.get('tokenMeter')
|
|
553
|
+
let measurement
|
|
554
|
+
if (meter !== undefined && typeof meter.measure === 'function') {
|
|
555
|
+
try {
|
|
556
|
+
const maybeMeasurement = meter.measure(session)
|
|
557
|
+
measurement = (maybeMeasurement !== undefined && maybeMeasurement !== null) ? maybeMeasurement : undefined
|
|
558
|
+
} catch {
|
|
559
|
+
measurement = undefined
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
// DIAGNOSTIC: log every facet on the threshold branch so a divergent total
|
|
563
|
+
// can be attributed (projection basis vs baseline kind/tokens vs surface sum
|
|
564
|
+
// vs nodes-window sum). Threshold hits are rare events, so unconditional
|
|
565
|
+
// DEBUG here is cheap.
|
|
566
|
+
const diagNodes = (measurement && Array.isArray(measurement.nodes)) ? measurement.nodes : []
|
|
567
|
+
const diagWindowSum = diagNodes.reduce((acc, n) => acc + (Number(n && n.tokens) > 0 ? Number(n.tokens) : 0), 0)
|
|
568
|
+
if (total >= settings.autoThresholdTokens) {
|
|
569
|
+
const baseline = (measurement && measurement.baseline) || undefined
|
|
570
|
+
const estFallback = estimateSessionTokens(session)
|
|
571
|
+
ctx.logger.debug(
|
|
572
|
+
`[force-compact] ${session.id}: MEASURE-DIAG basis=projectedTokens total=${total} `
|
|
573
|
+
+ `baseline=${baseline ? `${baseline?.kind}:${baseline?.tokens}` : 'none'} `
|
|
574
|
+
+ `delta=${measurement && typeof measurement.surfaceDeltaTokens === 'number' ? measurement.surfaceDeltaTokens : '?'} `
|
|
575
|
+
+ `surfaceTokens=${measurement && typeof measurement.surfaceTokens === 'number' ? measurement.surfaceTokens : '?'} `
|
|
576
|
+
+ `nodes=${diagNodes.length} windowSum=${diagWindowSum} charEst4=${estFallback}`
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
if (total < settings.autoThresholdTokens) {
|
|
580
|
+
ctx.logger.debug(`[force-compact] ${session.id}: total ~${total} tokens < threshold ${settings.autoThresholdTokens} — below gate, letting the request proceed`)
|
|
581
|
+
return false
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// PRE-FLIGHT DIAGNOSTIC — SURFACE-BASED FLOOR OBSERVATION (informational
|
|
585
|
+
// only; NEVER aborts the compaction). Rationale for dropping the early-return
|
|
586
|
+
// this block USED TO perform: `totalTokens` mixes a provider-reported USAGE
|
|
587
|
+
// baseline (which inflates on sessions that have already consumed context)
|
|
588
|
+
// with the live SURFACE DELTA. Shaving the whole surface window lowers the
|
|
589
|
+
// NEXT request's usage baseline dramatically (the provider re-baselines on
|
|
590
|
+
// the compacted surface), so projecting `total − maxRemovableHead` onto the
|
|
591
|
+
// CURRENT measurement is UNSOUND whenever the baseline is usage-flavored:
|
|
592
|
+
// it predicts "cannot cross" precisely in the regime where compaction helps
|
|
593
|
+
// most. Instead we LOG the floor arithmetic (useful attribution data — how
|
|
594
|
+
// much of `total` is baseline vs window vs delta) and ALWAYS fall through to
|
|
595
|
+
// the attempted compaction. The guard's `total` basis is now the projection's `projectedTokens` (provider-anchored, reacts to surface churn); the meter snapshot beside it is diagnostic-only, so the
|
|
596
|
+
// SURFACE-TOKENS ONLY (no usage-baseline water), so this naive projection is
|
|
597
|
+
// meaningful again; it nonetheless stays INFORMATIONAL — the per-region
|
|
598
|
+
// SHRINK GATE downstream makes the actual decision, and no separate
|
|
599
|
+
// blank-result cooldown exists anymore ("先压缩再说": every threshold-hit
|
|
600
|
+
// step attempts a fresh compaction).
|
|
601
|
+
const floorWindow = (measurement && Array.isArray(measurement.nodes) ? measurement.nodes : [])
|
|
602
|
+
.filter(n => n !== null && typeof n === 'object' && Number.isFinite(Number(n.tokens)))
|
|
603
|
+
const windowSumObserved = floorWindow.reduce((acc, n) => acc + Number(n.tokens), 0)
|
|
604
|
+
const maxRemovableObserved = Math.max(0, windowSumObserved - settings.retainLatestTokens)
|
|
605
|
+
const projectedAfterObserved = total - maxRemovableObserved
|
|
606
|
+
if (floorWindow.length > 0 && projectedAfterObserved >= settings.autoThresholdTokens) {
|
|
607
|
+
ctx.logger.debug(
|
|
608
|
+
`[force-compact] ${session.id}: PRE-FLIGHT OBSERVATION — total ${total} `
|
|
609
|
+
+ `(diagnostic baseline ${measurement && measurement.baseline ? `${measurement.baseline?.kind}:${measurement.baseline?.tokens}` : 'n/a'} `
|
|
610
|
+
+ `+ diagnostic surfaceDelta ${(measurement && measurement.surfaceDeltaTokens != null) ? measurement.surfaceDeltaTokens : '?'}); `
|
|
611
|
+
+ `surfaces window = ${windowSumObserved} tokens across ${floorWindow.length} nodes, `
|
|
612
|
+
+ `retains ~${settings.retainLatestTokens} → max removable head = ${maxRemovableObserved} tokens; `
|
|
613
|
+
+ `naive projected-after ${projectedAfterObserved} is >= threshold ${settings.autoThresholdTokens} `
|
|
614
|
+
+ `BUT the baseline is provider-reported usage (resets post-compaction), `
|
|
615
|
+
+ `so we PROCEED with the compaction attempt regardless. The downstream `
|
|
616
|
+
+ `shrink-gate protects against repeat no-ops (no BLANK cooldown anymore).`
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// At or above the threshold: do NOT request the model. Retain the latest
|
|
621
|
+
// `retainLatestTokens` of the surface VERBATIM, and send everything before
|
|
622
|
+
// that cutoff (the head) as ONE BATCH to the LLM summarizer. The loop retries
|
|
623
|
+
// the step against the shrunken context (retained tail unchanged).
|
|
624
|
+
ctx.logger.info(
|
|
625
|
+
`[force-compact] ${session.id}: context ~${total} tokens >= threshold ${settings.autoThresholdTokens}; `
|
|
626
|
+
+ `rejecting the model request and compacting the head while retaining the latest ~${settings.retainLatestTokens} tokens`,
|
|
627
|
+
)
|
|
628
|
+
const committed = await compactRetainingLatest(ctx, agent, signal, mode, measurement)
|
|
629
|
+
if (!committed) {
|
|
630
|
+
// BLANK OUTCOME — nothing shrank, so the NEXT step re-attempts at the same
|
|
631
|
+
// total. That is intentional ("先压缩再说"): a blank result never wedges the
|
|
632
|
+
// gate behind a high-water mark; the shrink-gate inside
|
|
633
|
+
// `compactRetainingLatest` plus the engine-side replay/failure caps absorb
|
|
634
|
+
// any repeat no-ops. Letting the request proceed.
|
|
635
|
+
ctx.logger.debug(`[force-compact] ${session.id}: threshold-gate compaction came back BLANK — letting the request proceed (will re-attempt on the next step).`)
|
|
636
|
+
} else {
|
|
637
|
+
ctx.logger.debug(`[force-compact] ${session.id}: threshold-gate compaction COMMITTED — letting the request proceed`)
|
|
638
|
+
}
|
|
639
|
+
return committed
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Whether a model request should be sent with thinking/reasoning disabled.
|
|
644
|
+
*
|
|
645
|
+
* Called from the `agent/request` Waterfall. When the `disableThinking`
|
|
646
|
+
* setting is on (default), the caller sets `reasoningEffort: 'off'` on the
|
|
647
|
+
* returned `LlmCallConfig`.
|
|
648
|
+
*
|
|
649
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
650
|
+
* @returns {Promise<boolean>}
|
|
651
|
+
*/
|
|
652
|
+
// `agent/request` entry — a throw here would corrupt EVERY outgoing model
|
|
653
|
+
// request. Contain it: any settings anomaly resolves `false` (thinking left
|
|
654
|
+
// at its provider default) rather than breaking the request path.
|
|
655
|
+
// Also append a UNIVERSAL-CRASH-NET diagnostic on any degradation.
|
|
656
|
+
async function __thinkingDisabledBody(ctx) {
|
|
657
|
+
try {
|
|
658
|
+
const settings = (await readSettings(ctx)) ?? DEFAULTS
|
|
659
|
+
return settings.disableThinking === true
|
|
660
|
+
} catch (error) {
|
|
661
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
662
|
+
ctx.logger.warn(`[force-compact] thinkingDisabled degraded to false — ${message}`)
|
|
663
|
+
try {
|
|
664
|
+
const lines = renderCrash('guard.thinkingDisabled', error, captureThrowSite())
|
|
665
|
+
for (const line of lines) appendDiag(line)
|
|
666
|
+
} catch (_netFailure) { /* swallow */ }
|
|
667
|
+
return false
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export const thinkingDisabled = guardFn('guard.thinkingDisabled', __thinkingDisabledBody)
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Coarse token estimate for a session's whole surface content, used only when
|
|
675
|
+
* the `tokenMeter` service is not mounted. Mirrors the character-based
|
|
676
|
+
* heuristic of `engine/checkpoint.js`.
|
|
677
|
+
* @param {import('@deepseek-ai/dsh-session').Session} session
|
|
678
|
+
* @returns {number}
|
|
679
|
+
*/
|
|
680
|
+
function estimateSessionTokens(session) {
|
|
681
|
+
// Coarse char-based estimator used ONLY when `tokenMeter` is absent. Must
|
|
682
|
+
// survive ANY receiver/event shape: every dereference is guarded so a
|
|
683
|
+
// malformed session (no `events`, missing `data`, non-object blocks) degrades
|
|
684
|
+
// to 0 rather than throwing — this feeds a fallback measurement, not a
|
|
685
|
+
// correctness path.
|
|
686
|
+
const CHARS_PER_TOKEN = 4
|
|
687
|
+
let chars = 0
|
|
688
|
+
const events = (session && Array.isArray(session.events)) ? session.events : []
|
|
689
|
+
for (const event of events) {
|
|
690
|
+
if (event === null || typeof event !== 'object') continue
|
|
691
|
+
let content
|
|
692
|
+
const data = (event.data && typeof event.data === 'object') ? event.data : undefined
|
|
693
|
+
if (event.type === 'user/message') content = data.content
|
|
694
|
+
else if (event.type === 'assistant/message') {
|
|
695
|
+
content = (data.message && typeof data.message === 'object') ? data.message.content : undefined
|
|
696
|
+
} else if (event.type === 'tool/result') {
|
|
697
|
+
const msg = (data.message && typeof data.message === 'object') ? data.message : undefined
|
|
698
|
+
content = msg !== undefined ? msg.content : undefined
|
|
699
|
+
}
|
|
700
|
+
if (!Array.isArray(content)) continue
|
|
701
|
+
for (const block of content) {
|
|
702
|
+
if (block && typeof block === 'object' && typeof block.text === 'string') chars += block.text.length
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return Math.ceil(chars / CHARS_PER_TOKEN)
|
|
706
|
+
}
|