dsh-switchman 1.10.3 → 1.11.1
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/README.de.md +11 -10
- package/README.es.md +11 -10
- package/README.fr.md +11 -10
- package/README.it.md +11 -10
- package/README.ja.md +11 -10
- package/README.ko.md +11 -10
- package/README.md +19 -10
- package/README.pt.md +11 -10
- package/README.ru.md +11 -10
- package/README.zh-TW.md +11 -10
- package/README.zh.md +11 -10
- package/client.js +326 -43
- package/cordis.patch.yml +24 -21
- package/host/config.js +57 -10
- package/host/context-trim/fork.js +5 -1
- package/host/context-trim/index.js +340 -183
- package/host/context-trim/metrics.js +22 -14
- package/host/context-trim/state.js +351 -50
- package/host/context-trim/summarizer.js +147 -33
- package/host/context-trim/surface.js +222 -2
- package/host/context-trim/trigger.js +170 -51
- package/host/context-trim/view.js +132 -16
- package/host/context-watch.js +81 -73
- package/host/dispatch.js +30 -5
- package/host/routes.js +64 -4
- package/host/teams.js +2 -1
- package/package.json +2 -2
|
@@ -12,23 +12,29 @@
|
|
|
12
12
|
* - agent/status idle hook: the only place fold decisions run (design §4:
|
|
13
13
|
* fold at the idle boundary, never mid-stream).
|
|
14
14
|
* - ctx_trim_fold / ctx_trim_status tools: the L1 advisory surface — the
|
|
15
|
-
* session model may fold ranges itself and inspect
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* session model may fold ranges itself and inspect compressible ranges
|
|
16
|
+
* (true sizes only — NEVER a fill percentage or water level; bili lesson
|
|
17
|
+
* #2, docs/ladder-fold.md).
|
|
18
|
+
*
|
|
19
|
+
* MODEL-VISIBILITY INVARIANT: the trim layer registers NO systemPrompt
|
|
20
|
+
* context line. Water levels, view-token estimates, growth numbers, and
|
|
21
|
+
* fold counts are engine internals; the model sees only the byte-stable
|
|
22
|
+
* protocol head and (on demand) the true-size recommendation list. Human
|
|
23
|
+
* diagnostics live in decisions.log.
|
|
19
24
|
*
|
|
20
25
|
* Config fields (host/config.js, all volatile): trimEnabled, trimMode
|
|
21
26
|
* ("off"|"advisory"|"auto"), trimGrowthTokens, trimIncrementTokens,
|
|
22
27
|
* trimPressurePercent, trimEmergencyPercent, trimWindowTokens,
|
|
23
|
-
* trimProtectedTail,
|
|
24
|
-
*
|
|
28
|
+
* trimProtectedTail, trimMaxFoldsPerHour, trimSideModel, trimFoldFallbackModel, trimStateDir,
|
|
29
|
+
* trimLadderEnabled, trimLadderT2Tokens, trimLadderT2MinBlocks,
|
|
30
|
+
* trimLadderT3Tokens, trimLadderT3MinBlocks.
|
|
25
31
|
*/
|
|
26
32
|
|
|
27
33
|
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
28
34
|
import { join } from "node:path";
|
|
29
|
-
import { buildView, estimateTokensOf, injectView, ledgerIsStale, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
|
|
35
|
+
import { buildView, estimateTokensOf, injectView, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
|
|
30
36
|
import { computeFoldableRange, evaluateTrigger, EMERGENCY_COOLDOWN_MS } from "./trigger.js";
|
|
31
|
-
import { mechanicalSummary, renderMaterial, summarizeWithModel } from "./summarizer.js";
|
|
37
|
+
import { LADDER_SYSTEM_PROMPT, mechanicalSummary, renderLadderMaterial, renderMaterial, summarizeWithModel } from "./summarizer.js";
|
|
32
38
|
import { createStore } from "./state.js";
|
|
33
39
|
import { createMetrics, extractUsage, promptTokensOf, wrapStreamForMetrics } from "./metrics.js";
|
|
34
40
|
import { forkLinkOf, loadForkLinks, recordForkLink, resolveInheritance, INHERIT_WINDOW_MS } from "./fork.js";
|
|
@@ -42,13 +48,16 @@ import { childSessionIdOf } from "../subagent-model.js";
|
|
|
42
48
|
* agent/status and assembleContextFor's context.agent are both the object
|
|
43
49
|
* form). Mirrors the host/context-watch.js:122 precedent. Returns null when
|
|
44
50
|
* nothing usable is present. */
|
|
45
|
-
// ---- cross-layer
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
+
// ---- cross-layer meter registries (meter duality, design §8) ---------------
|
|
52
|
+
// Per session: the latest outbound-view token estimate plus the last sampled
|
|
53
|
+
// provider prompt, refreshed on every ACCEPTED main llm/stream call (the
|
|
54
|
+
// side-dispatch guard keeps auxiliary calls out of both). host/context-watch.js
|
|
55
|
+
// reads the wire-domain bridge over these (recentTrimPromptTokensOf:
|
|
56
|
+
// max(view, wire)) so the watermark meters on the same boundary as the trim
|
|
57
|
+
// pressure tiers; the wire-truth gate decides when neither registry may drive
|
|
58
|
+
// it and the stored tally takes over.
|
|
51
59
|
const trimViewBySession = new Map(); // sessionId -> { viewTokens, at, inject, lastNoneAt }
|
|
60
|
+
const trimWireBySession = new Map(); // sessionId -> { promptTokens, at } — last sampled provider prompt of an accepted MAIN dispatch (side calls never write; a tiny inherited-id usage sample must not lower the watermark's wire floor)
|
|
52
61
|
/** Freshness TTL for the in-memory view/injection registry (watermark and
|
|
53
62
|
* banner display only). NEVER a fold gate: gating folds on "did the surface
|
|
54
63
|
* channel ship recently" deadlocks resumed sessions (2026-11-05). The fold
|
|
@@ -72,33 +81,88 @@ const SIDE_DISPATCH_SKIP_BUDGET = 2;
|
|
|
72
81
|
* accepted main dispatch). */
|
|
73
82
|
const sideDispatchSkipsBySession = new Map();
|
|
74
83
|
|
|
84
|
+
/** Shared wire-truth gate (docs/context-trim.md): the view-registry entry for
|
|
85
|
+
* one session when the recent dispatch stream provably carried the view —
|
|
86
|
+
* null when missing, stale, injected "none", or inside the sticky lastNoneAt
|
|
87
|
+
* window (mixed sessions exist — one real session logged 46 none + 14
|
|
88
|
+
* swapped). Observed 2026-11-04: a view that never reaches the wire must not
|
|
89
|
+
* drive the watermark (view ~80k vs a 176k wire while the force tier stayed
|
|
90
|
+
* silent and the auto-handover never fired). Both watermark accessors read
|
|
91
|
+
* through this gate; itself fail-open. */
|
|
92
|
+
function gatedViewEntry(sessionId, maxAgeMs) {
|
|
93
|
+
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
94
|
+
const entry = trimViewBySession.get(sessionId);
|
|
95
|
+
if (entry === undefined) return null;
|
|
96
|
+
if (Date.now() - entry.at > maxAgeMs) return null;
|
|
97
|
+
if (entry.inject === "none") return null;
|
|
98
|
+
if (Number.isFinite(entry.lastNoneAt) && entry.lastNoneAt > 0 && Date.now() - entry.lastNoneAt < maxAgeMs) return null;
|
|
99
|
+
return entry;
|
|
100
|
+
}
|
|
101
|
+
|
|
75
102
|
/** Most recent outbound-view token estimate for a session, or null when
|
|
76
|
-
* missing, non-positive,
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* tally is the only truthful meter). The gate is deliberately sticky: a
|
|
80
|
-
* single mutable dispatch right after frozen ones re-arms maybeFold, but
|
|
81
|
-
* the WATERMARK keeps falling back to the tally until no "none" occurred
|
|
82
|
-
* within the TTL window (mixed sessions exist — one real session logged 46
|
|
83
|
-
* none + 14 swapped). Read by the watermark layer (meter duality); itself
|
|
84
|
-
* fail-open. */
|
|
103
|
+
* missing, non-positive, or gate-rejected. The bare-view half of the meter
|
|
104
|
+
* duality bridge — kept for diagnostics and as the gate primitive; the
|
|
105
|
+
* watermark layer reads the wire-domain accessor below. */
|
|
85
106
|
export function recentTrimViewTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
|
|
86
107
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (entry === undefined) return null;
|
|
90
|
-
if (Date.now() - entry.at > maxAgeMs) return null;
|
|
91
|
-
// Wire-truth gate: a view that never reaches the wire must not drive
|
|
92
|
-
// the watermark (observed 2026-11-04: view ~80k vs a 176k wire while
|
|
93
|
-
// the force tier stayed silent and the auto-handover never fired).
|
|
94
|
-
if (entry.inject === "none") return null;
|
|
95
|
-
if (Number.isFinite(entry.lastNoneAt) && entry.lastNoneAt > 0 && Date.now() - entry.lastNoneAt < maxAgeMs) return null;
|
|
96
|
-
return Number.isFinite(entry.viewTokens) && entry.viewTokens > 0 ? entry.viewTokens : null;
|
|
108
|
+
const entry = gatedViewEntry(sessionId, maxAgeMs);
|
|
109
|
+
return entry !== null && Number.isFinite(entry.viewTokens) && entry.viewTokens > 0 ? entry.viewTokens : null;
|
|
97
110
|
} catch {
|
|
98
111
|
return null;
|
|
99
112
|
}
|
|
100
113
|
}
|
|
101
114
|
|
|
115
|
+
/** Meter-duality bridge, wire domain (design §8): the same number the trim
|
|
116
|
+
* pressure tiers evaluate in maybeFold — Math.max(outbound view estimate,
|
|
117
|
+
* the last sampled provider prompt) — or null when the gate rejects or
|
|
118
|
+
* neither component is fresh. The watermark layer reads THIS (not the bare
|
|
119
|
+
* view) so its soft/hard/force tiers and the read budget sit on the same
|
|
120
|
+
* boundary as the fold engine: the view estimate never counts the
|
|
121
|
+
* system/tool sections (a session-shape-dependent mass — live decision-log
|
|
122
|
+
* pairs drift −5k…+6k between view and wire in steady state), so a
|
|
123
|
+
* view-only watermark tightens every protective tier late by exactly that
|
|
124
|
+
* mass, and the wmHardTokens-95k ≈ trim-pressure-97.5k adjacency (65%
|
|
125
|
+
* of the 150k window) is silently defeated. The wire half comes from
|
|
126
|
+
* accepted MAIN dispatches only (noteTrimWireTokens below). Same sticky
|
|
127
|
+
* gate as the view bridge;
|
|
128
|
+
* fail-open — null means the caller falls back to the stored tally. */
|
|
129
|
+
export function recentTrimPromptTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
|
|
130
|
+
try {
|
|
131
|
+
const entry = gatedViewEntry(sessionId, maxAgeMs);
|
|
132
|
+
if (entry === null) return null;
|
|
133
|
+
let best = 0;
|
|
134
|
+
if (Number.isFinite(entry.viewTokens) && entry.viewTokens > 0) best = entry.viewTokens;
|
|
135
|
+
const wire = trimWireBySession.get(sessionId);
|
|
136
|
+
if (wire !== undefined && Date.now() - wire.at <= maxAgeMs && Number.isFinite(wire.promptTokens) && wire.promptTokens > best) best = wire.promptTokens;
|
|
137
|
+
return best > 0 ? best : null;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Record the provider prompt size (uncached + cacheRead + cacheWrite) of one
|
|
144
|
+
* usage sample from an ACCEPTED main dispatch. Side dispatches (fold-span
|
|
145
|
+
* re-sends, title generation, web-search summaries) sample usage on the same
|
|
146
|
+
* session id but must never reach this registry: their tiny prompts would
|
|
147
|
+
* LOWER the watermark's wire floor below the main history's real prompt —
|
|
148
|
+
* exactly the pollution the side-dispatch guard keeps out of the view
|
|
149
|
+
* registry. Fail-open, bounded like the view registry. */
|
|
150
|
+
function noteTrimWireTokens(sessionId, usage) {
|
|
151
|
+
try {
|
|
152
|
+
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
153
|
+
const promptTokens = promptTokensOf({
|
|
154
|
+
lastInputTokens: usage.inputTokens,
|
|
155
|
+
cachedTokens: usage.cachedTokens,
|
|
156
|
+
cacheWriteTokens: usage.cacheWriteTokens,
|
|
157
|
+
});
|
|
158
|
+
if (promptTokens === null) return;
|
|
159
|
+
trimWireBySession.set(sessionId, { promptTokens, at: Date.now() });
|
|
160
|
+
if (trimWireBySession.size > 200) trimWireBySession.delete(trimWireBySession.keys().next().value);
|
|
161
|
+
} catch {
|
|
162
|
+
/* observability must never throw */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
102
166
|
/** Injection outcome of the last dispatch for one session ("in-place" |
|
|
103
167
|
* "swapped" | "none"), or null when unknown or stale. Read by maybeFold:
|
|
104
168
|
* when the wire cannot carry the view, folding cannot relieve anything and
|
|
@@ -124,14 +188,6 @@ export function agentSessionIdOf(agent) {
|
|
|
124
188
|
return null;
|
|
125
189
|
}
|
|
126
190
|
|
|
127
|
-
/** Display label for the banner: object → name, string → itself, else the
|
|
128
|
-
* session id (never prints [object Object]). */
|
|
129
|
-
export function agentLabelOf(agent) {
|
|
130
|
-
if (typeof agent === "string") return agent;
|
|
131
|
-
if (agent !== null && typeof agent === "object" && typeof agent.name === "string" && agent.name !== "") return agent.name;
|
|
132
|
-
return agentSessionIdOf(agent);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
191
|
// ---- config field readers (fail-open defaults) -------------------------------
|
|
136
192
|
|
|
137
193
|
function readBool(config, key, fallback) {
|
|
@@ -189,12 +245,14 @@ function readRoute(config, key) {
|
|
|
189
245
|
// ---- prompt texts -------------------------------------------------------------
|
|
190
246
|
|
|
191
247
|
/** Byte-stable head section: the trim protocol (digit-free, like the
|
|
192
|
-
* watermark protocol, so it stays out of KV invariants' way).
|
|
248
|
+
* watermark protocol, so it stays out of KV invariants' way). States the
|
|
249
|
+
* number-free disclosure policy explicitly — the model never receives
|
|
250
|
+
* water levels or progress numbers from this engine. */
|
|
193
251
|
export const TRIM_PROTOCOL_TEXT = [
|
|
194
|
-
"[SWITCHMAN:TRIM] This session has automatic context trimming.",
|
|
252
|
+
"[SWITCHMAN:TRIM] This session has automatic context trimming. The engine folds consumed ranges itself and never reports sizes, percentages, or progress to you — no action or pacing is expected of you on that front.",
|
|
195
253
|
"- Old message ranges may be replaced by ONE synthetic [SWITCHMAN:TRIM] summary message; treat those summaries as faithful compressions, and message refs inside them as historical anchors.",
|
|
196
254
|
"- NEVER contradict or re-derive content a summary already settled; build on it.",
|
|
197
|
-
"-
|
|
255
|
+
"- You may fold a contiguous range yourself with the ctx_trim_fold tool (give firstId/lastId message refs and a summary following the compression rules); ctx_trim_status lists the compressible ranges with their true sizes.",
|
|
198
256
|
"- Tool-call pairs (an assistant tool call and its results) must always be folded together or not at all.",
|
|
199
257
|
].join("\n");
|
|
200
258
|
|
|
@@ -209,37 +267,6 @@ export function effectiveWindowTokens(resolved, cap) {
|
|
|
209
267
|
return Math.min(a, b);
|
|
210
268
|
}
|
|
211
269
|
|
|
212
|
-
/** Live context line renderer (pure; exported for tests). Silent in "off"
|
|
213
|
-
* mode and whenever nothing has been measured yet. wireRaw marks a session
|
|
214
|
-
* whose last dispatch could not carry the fold view (frozen request) — the
|
|
215
|
-
* displayed view is then meter-only and the watermark governs off the
|
|
216
|
-
* provider tally. */
|
|
217
|
-
export function renderTrimContextLine(agent, { enabled, mode, viewTokens, windowTokens, folds, growthTokens, lastModel, wireRaw = false }) {
|
|
218
|
-
if (!enabled) return "";
|
|
219
|
-
if (mode === "off") return "";
|
|
220
|
-
if (agent === null || agent === undefined || agent === "") return "";
|
|
221
|
-
const hasView = Number.isFinite(viewTokens) && viewTokens > 0;
|
|
222
|
-
const hasFolds = Number.isFinite(folds) && folds > 0;
|
|
223
|
-
if (!hasView && !hasFolds) return "";
|
|
224
|
-
const kilo = (n) => `${Math.round(n / 1000)}k`;
|
|
225
|
-
const parts = [];
|
|
226
|
-
if (hasView) {
|
|
227
|
-
let usage = "";
|
|
228
|
-
if (Number.isFinite(windowTokens) && windowTokens > 0) usage = ` (${Math.round((viewTokens / windowTokens) * 100)}% of window ${kilo(windowTokens)})`;
|
|
229
|
-
else usage = " (window unknown — pressure tiers off)";
|
|
230
|
-
parts.push(`view ~${kilo(viewTokens)} tokens${usage}`);
|
|
231
|
-
if (wireRaw) parts.push("wire=raw (folds not shipping; watermark reads the tally)");
|
|
232
|
-
}
|
|
233
|
-
if (hasFolds) {
|
|
234
|
-
parts.push(`folds ${folds}`);
|
|
235
|
-
if (Number.isFinite(growthTokens)) parts.push(`growth ~${kilo(growthTokens)} since last fold`);
|
|
236
|
-
}
|
|
237
|
-
if (lastModel !== null && typeof lastModel === "object" && typeof lastModel.model === "string" && lastModel.model !== "") parts.push(`model ${lastModel.model}`);
|
|
238
|
-
if (parts.length === 0) return "";
|
|
239
|
-
const modeNote = mode === "advisory" ? "advisory: fold via ctx_trim_fold when context feels heavy" : "auto folding active";
|
|
240
|
-
return `[SWITCHMAN:TRIM] agent ${agent}: ${parts.join(", ")}; ${modeNote}.\n`;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
270
|
// ---- layer ---------------------------------------------------------------------
|
|
244
271
|
|
|
245
272
|
/** Register the context-trim layer. Wired from the plugin root after
|
|
@@ -254,6 +281,28 @@ export function applyContextTrim(ctx, config) {
|
|
|
254
281
|
}
|
|
255
282
|
};
|
|
256
283
|
|
|
284
|
+
// Reload/teardown marker: HMR disposes and remounts this section on every
|
|
285
|
+
// plugin edit (the profile's node_modules entry is a dev symlink into this
|
|
286
|
+
// checkout). An in-flight fold whose llm stream is cut by the dispose ends
|
|
287
|
+
// as a clean zero-chunk stream — inside the summarizer that is
|
|
288
|
+
// indistinguishable from a genuine empty model reply. Cycles record their
|
|
289
|
+
// start time; a dispose at/after that time reclassifies the outcome as
|
|
290
|
+
// reload-aborted so it neither climbs the failure ladder nor reads as a
|
|
291
|
+
// model failure in decisions.log.
|
|
292
|
+
let disposedAt = 0;
|
|
293
|
+
try {
|
|
294
|
+
// cordis emits no "dispose" event: self-teardown arrives as
|
|
295
|
+
// "internal/plugin" carrying OUR fiber with its uid CLEARED — uid is a
|
|
296
|
+
// positive counter at construction (vendor/cordis fiber.ts:239) and is
|
|
297
|
+
// set to null right before the disposal emit (:274-275; the DISPOSED
|
|
298
|
+
// state is defined as uid === null at :581). Same guard DSH ships in
|
|
299
|
+
// mcp-manager:249 and client-ui-cot-translation mount.ts:123.
|
|
300
|
+
ctx.on("internal/plugin", (fiber) => { if (fiber === ctx.fiber && fiber.uid === null) disposedAt = Date.now(); });
|
|
301
|
+
} catch {
|
|
302
|
+
/* lifecycle events unavailable on this ctx — flag stays 0, behavior unchanged */
|
|
303
|
+
}
|
|
304
|
+
const disposedSince = (since) => disposedAt !== 0 && disposedAt >= since;
|
|
305
|
+
|
|
257
306
|
// Per-process pipeline state (process-wide by design: ledgers persist on
|
|
258
307
|
// disk per session, metrics/captures live per session in memory).
|
|
259
308
|
const store = createStore({ stateDir: readString(config, "trimStateDir", null) ?? undefined, warn });
|
|
@@ -276,21 +325,6 @@ export function applyContextTrim(ctx, config) {
|
|
|
276
325
|
return false;
|
|
277
326
|
}
|
|
278
327
|
};
|
|
279
|
-
// Durable surface-channel EVIDENCE for the banner: a block that already
|
|
280
|
-
// SHIPPED a replacement (surfaceReplaced) proves the channel reaches the
|
|
281
|
-
// wire, even across a process restart and after the in-memory recency
|
|
282
|
-
// window expired. A commit-time capture (surfaceSeqs) does NOT — the block
|
|
283
|
-
// may never ship, and a capture must not silence the honest wire=raw
|
|
284
|
-
// warning. Display only — the fold gate uses the live capability probe.
|
|
285
|
-
const ledgerShowsShippedReplacement = (sessionId) => {
|
|
286
|
-
try {
|
|
287
|
-
const blocks = store.ledgerOf(sessionId)?.blocks;
|
|
288
|
-
if (!Array.isArray(blocks)) return false;
|
|
289
|
-
return blocks.some((block) => block?.surfaceReplaced !== null && block?.surfaceReplaced !== undefined);
|
|
290
|
-
} catch {
|
|
291
|
-
return false;
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
328
|
const metrics = createMetrics();
|
|
295
329
|
|
|
296
330
|
/** Most recent provider prompt size for one session, or null when unknown.
|
|
@@ -444,12 +478,51 @@ export function applyContextTrim(ctx, config) {
|
|
|
444
478
|
};
|
|
445
479
|
const thresholdsNow = () => ({
|
|
446
480
|
growthTokens: readNumber(config, "trimGrowthTokens", 22_500),
|
|
447
|
-
incrementTokens: readNumber(config, "trimIncrementTokens",
|
|
448
|
-
pressurePercent: readNumber(config, "trimPressurePercent",
|
|
481
|
+
incrementTokens: readNumber(config, "trimIncrementTokens", 25_000),
|
|
482
|
+
pressurePercent: readNumber(config, "trimPressurePercent", 65),
|
|
449
483
|
emergencyPercent: readNumber(config, "trimEmergencyPercent", 95),
|
|
450
|
-
protectedTail: Math.max(0, Math.round(readNumber(config, "trimProtectedTail",
|
|
451
|
-
maxFoldsPerHour: readNumber(config, "trimMaxFoldsPerHour",
|
|
484
|
+
protectedTail: Math.max(0, Math.round(readNumber(config, "trimProtectedTail", 10))),
|
|
485
|
+
maxFoldsPerHour: readNumber(config, "trimMaxFoldsPerHour", 30),
|
|
486
|
+
ladderT2Tokens: readNumber(config, "trimLadderT2Tokens", 8_000),
|
|
487
|
+
ladderT2MinBlocks: readNumber(config, "trimLadderT2MinBlocks", 3),
|
|
488
|
+
ladderT3Tokens: readNumber(config, "trimLadderT3Tokens", 6_000),
|
|
489
|
+
ladderT3MinBlocks: readNumber(config, "trimLadderT3MinBlocks", 2),
|
|
452
490
|
});
|
|
491
|
+
const ladderEnabledNow = () => readBool(config, "trimLadderEnabled", true);
|
|
492
|
+
/** CJK-aware summary-mass estimator for the ladder census (same units
|
|
493
|
+
* the view estimate speaks, applied to summary text). */
|
|
494
|
+
const summaryTokensOf = (text) => messageTokens({ role: "user", content: [{ type: "text", text: typeof text === "string" ? text : "" }] });
|
|
495
|
+
/** Maximal runs of consecutive ACTIVE tier-1 / tier-2 blocks in ledger
|
|
496
|
+
* order — the ladder's candidate consumes sets (docs/ladder-fold.md).
|
|
497
|
+
* A run breaks on an inactive member, a tier change, or a tier-3 block
|
|
498
|
+
* interleaving. Runs carry { consumes, mass, count }; mass is the
|
|
499
|
+
* CJK-aware summary-mass estimate. Pure over the ledger. */
|
|
500
|
+
function ladderRunsOf(ledger) {
|
|
501
|
+
const runs = { t2: [], t3: [] };
|
|
502
|
+
if (ledger === null || !Array.isArray(ledger.blocks)) return runs;
|
|
503
|
+
let cur = null;
|
|
504
|
+
const flush = () => {
|
|
505
|
+
if (cur !== null && cur.ids.length > 0) {
|
|
506
|
+
const run = { consumes: cur.ids, mass: cur.mass, count: cur.ids.length };
|
|
507
|
+
if (cur.tier === 1) runs.t2.push(run);
|
|
508
|
+
else if (cur.tier === 2) runs.t3.push(run);
|
|
509
|
+
}
|
|
510
|
+
cur = null;
|
|
511
|
+
};
|
|
512
|
+
for (const block of ledger.blocks) {
|
|
513
|
+
const tier = block?.active === true && (block.tier === 1 || block.tier === 2) ? block.tier : null;
|
|
514
|
+
if (tier === null || (cur !== null && cur.tier !== tier)) {
|
|
515
|
+
flush();
|
|
516
|
+
}
|
|
517
|
+
if (tier !== null && cur === null) cur = { tier, ids: [], mass: 0 };
|
|
518
|
+
if (cur !== null) {
|
|
519
|
+
cur.ids.push(block.id);
|
|
520
|
+
cur.mass += summaryTokensOf(block.summary);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
flush();
|
|
524
|
+
return runs;
|
|
525
|
+
}
|
|
453
526
|
/** Effective-window cap: explicit trimWindowTokens, else auto-follow
|
|
454
527
|
* wmForceTokens (keeps fold tiers ahead of the handover tier). */
|
|
455
528
|
const windowCapNow = () => {
|
|
@@ -530,7 +603,11 @@ export function applyContextTrim(ctx, config) {
|
|
|
530
603
|
// some dispatch options are frozen on top of that. injectView picks the
|
|
531
604
|
// best strategy per dispatch and fails open; the view-applied line
|
|
532
605
|
// records which strategy was used.
|
|
533
|
-
|
|
606
|
+
// [2026-11-07]-[trimMode "off" means measure only]-[the view build runs
|
|
607
|
+
// for the meter, but the fold must not reach the wire — inspection
|
|
608
|
+
// report P0-2: off previously still spliced the folded view into the
|
|
609
|
+
// dispatch.]
|
|
610
|
+
const injected = modeNow() !== "off" ? injectView(options, messages, view) : "nothing-to-apply";
|
|
534
611
|
const viewTokens = estimateTokensOf(view.messages);
|
|
535
612
|
const blocksCount = Array.isArray(ledger.blocks) ? ledger.blocks.length : 0;
|
|
536
613
|
// Wire truth in the row: the most recent provider prompt size (from the
|
|
@@ -558,8 +635,17 @@ export function applyContextTrim(ctx, config) {
|
|
|
558
635
|
// history below the floor, so the guard must not stick: after
|
|
559
636
|
// SIDE_DISPATCH_SKIP_BUDGET consecutive skips the dispatch is accepted
|
|
560
637
|
// as main, which re-arms capture/baseline on the compacted history.
|
|
561
|
-
|
|
562
|
-
|
|
638
|
+
// [2026-11-07]-[fold-span dispatches are self-noise, not side
|
|
639
|
+
// candidates]-[an in-flight fold cycle's own summarizer re-sends used
|
|
640
|
+
// to consume the side-dispatch skip budget: three consecutive span
|
|
641
|
+
// dispatches got "budget exhausted — accepting as main" and polluted
|
|
642
|
+
// the capture/baseline (the 2026-11-04 regression path). They now
|
|
643
|
+
// bypass unconditionally — never counted, never main.]
|
|
644
|
+
if (inFlight.has(sessionId)) {
|
|
645
|
+
decisionLog(`session=${sessionId} fold-span dispatch skipped view=${Math.round(viewTokens)} msgs=${messages.length}`);
|
|
646
|
+
return { sessionId, viewTokens, sideDispatch: true };
|
|
647
|
+
}
|
|
648
|
+
const sideCandidate = view.applied === 0 && messages.length < SIDE_DISPATCH_MIN_MESSAGES && (blocksCount > 0 || captures.has(sessionId) || trimViewBySession.has(sessionId));
|
|
563
649
|
const skipCount = sideDispatchSkipsBySession.get(sessionId) ?? 0;
|
|
564
650
|
if (sideCandidate && skipCount < SIDE_DISPATCH_SKIP_BUDGET) {
|
|
565
651
|
sideDispatchSkipsBySession.set(sessionId, skipCount + 1);
|
|
@@ -613,11 +699,60 @@ export function applyContextTrim(ctx, config) {
|
|
|
613
699
|
return null;
|
|
614
700
|
}
|
|
615
701
|
|
|
702
|
+
/** Resolve the fold-fallback route (trimFoldFallbackModel): the second
|
|
703
|
+
* chance when the side/session route fails, and the last model stop
|
|
704
|
+
* before a raw pressure fold degrades to mechanical. Null = no fallback. */
|
|
705
|
+
function foldFallbackRouteFor() {
|
|
706
|
+
return readRoute(config, "trimFoldFallbackModel");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** True when two resolved routes name the same provider+model (a fallback
|
|
710
|
+
* identical to the primary route would only double the failure latency). */
|
|
711
|
+
function sameRoute(a, b) {
|
|
712
|
+
return a !== null && b !== null && a.provider === b.provider && a.model === b.model;
|
|
713
|
+
}
|
|
714
|
+
|
|
616
715
|
/** Run one fold cycle for a session (called at the idle boundary ONLY).
|
|
617
716
|
* Returns a short outcome string for logging. */
|
|
618
|
-
async function runFoldCycle(sessionId, decision) {
|
|
717
|
+
async function runFoldCycle(sessionId, decision, cycleStartedAt) {
|
|
619
718
|
const capture = captures.get(sessionId);
|
|
620
719
|
if (capture === undefined) return "no-capture";
|
|
720
|
+
const { protectedTail } = thresholdsNow();
|
|
721
|
+
// Ladder cycle (docs/ladder-fold.md): distill the decision's consumed
|
|
722
|
+
// run of block summaries into ONE replacement. Never mechanical — a
|
|
723
|
+
// failed distillation skips the cycle (fail-open + failure ladder).
|
|
724
|
+
if (decision.kind === "ladder") {
|
|
725
|
+
const ledgerBefore = store.ledgerOf(sessionId);
|
|
726
|
+
const byId = new Map(Array.isArray(ledgerBefore.blocks) ? ledgerBefore.blocks.map((block) => [block.id, block]) : []);
|
|
727
|
+
const consumed = decision.consumes.map((id) => byId.get(id)).filter((block) => block !== undefined);
|
|
728
|
+
if (consumed.length !== decision.consumes.length) return "ladder-consumed-missing";
|
|
729
|
+
const llm = ctx.get("llm", false);
|
|
730
|
+
const route = sideRouteFor(sessionId);
|
|
731
|
+
const fallback = foldFallbackRouteFor();
|
|
732
|
+
const approxTokens = consumed.reduce((sum, block) => sum + (Number.isFinite(block.approxTokens) && block.approxTokens > 0 ? block.approxTokens : 0), 0);
|
|
733
|
+
const summarize = (r, tag) => summarizeWithModel(llm, r, renderLadderMaterial(consumed), {
|
|
734
|
+
systemPrompt: LADDER_SYSTEM_PROMPT,
|
|
735
|
+
onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} blocks=${consumed.length} ~${approxTokens}t ladder=${decision.tier}`),
|
|
736
|
+
});
|
|
737
|
+
let summary = route !== null ? await summarize(route, "side") : null;
|
|
738
|
+
if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
|
|
739
|
+
// Ladder folds never degrade to mechanical (docs/ladder-fold.md):
|
|
740
|
+
// after both routes fail the cycle stays fail-open with backoff.
|
|
741
|
+
if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
|
|
742
|
+
const result = store.commitFold(
|
|
743
|
+
sessionId,
|
|
744
|
+
{ consumes: decision.consumes.slice(), summary, kind: "summary", tier: decision.tier === "ladder-3" ? 3 : 2, approxTokens },
|
|
745
|
+
null, // ladder commits validate against the ledger, never the capture
|
|
746
|
+
);
|
|
747
|
+
if (!result.ok) return `commit-rejected:${result.reason}`;
|
|
748
|
+
// Post-fold low baseline: the ladder shrinks the view; re-anchor
|
|
749
|
+
// the growth gate at the new low so it does not re-fire on stale
|
|
750
|
+
// anchor math (same pair semantics as raw folds).
|
|
751
|
+
const lowLedger = store.ledgerOf(sessionId);
|
|
752
|
+
const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail }).messages);
|
|
753
|
+
store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
|
|
754
|
+
return `folded:${decision.tier}:${decision.consumes.length} blocks (~${approxTokens} tokens)`;
|
|
755
|
+
}
|
|
621
756
|
const { startIdx, endIdx } = decision;
|
|
622
757
|
const slice = capture.messages.slice(startIdx, endIdx + 1);
|
|
623
758
|
if (slice.length === 0) return "empty-slice";
|
|
@@ -626,6 +761,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
626
761
|
const approxTokens = estimateTokensOf(slice);
|
|
627
762
|
let summary = null;
|
|
628
763
|
let kind = "summary";
|
|
764
|
+
let degraded = false; // set when a PRESSURE fold falls back to mechanical
|
|
629
765
|
if (decision.action === "emergency") {
|
|
630
766
|
// Emergency cooldown timestamp set BEFORE the cycle: a commitFold
|
|
631
767
|
// rejection (e.g. duplicate-range) must cool the emergency tier
|
|
@@ -639,10 +775,25 @@ export function applyContextTrim(ctx, config) {
|
|
|
639
775
|
} else {
|
|
640
776
|
const llm = ctx.get("llm", false);
|
|
641
777
|
const route = sideRouteFor(sessionId);
|
|
642
|
-
|
|
643
|
-
|
|
778
|
+
const fallback = foldFallbackRouteFor();
|
|
779
|
+
const material = renderMaterial(capture.messages, startIdx, endIdx, refOf);
|
|
780
|
+
const summarize = (r, tag) => summarizeWithModel(llm, r, material, {
|
|
781
|
+
onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
|
|
644
782
|
});
|
|
645
|
-
|
|
783
|
+
summary = route !== null ? await summarize(route, "side") : null;
|
|
784
|
+
if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
|
|
785
|
+
// Last resort for PRESSURE folds only: relief beats fidelity when
|
|
786
|
+
// the window is nearly gone; growth folds stay fail-open (a
|
|
787
|
+
// mechanical growth fold would burn fold budget on junk). The
|
|
788
|
+
// outcome row marks the degrade so the log shows why no model
|
|
789
|
+
// summary landed.
|
|
790
|
+
if (summary === null && decision.tier === "pressure") {
|
|
791
|
+
if (disposedSince(cycleStartedAt)) return "reload-aborted";
|
|
792
|
+
summary = mechanicalSummary(capture.messages, startIdx, endIdx, refOf);
|
|
793
|
+
kind = "mechanical";
|
|
794
|
+
degraded = true;
|
|
795
|
+
}
|
|
796
|
+
if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
|
|
646
797
|
}
|
|
647
798
|
const positions = refPositions(capture.messages);
|
|
648
799
|
const result = store.commitFold(
|
|
@@ -659,9 +810,9 @@ export function applyContextTrim(ctx, config) {
|
|
|
659
810
|
// post-fold low (tier-a anchor — keeps the gate armed after folds
|
|
660
811
|
// that commit at high view points).
|
|
661
812
|
const lowLedger = store.ledgerOf(sessionId);
|
|
662
|
-
const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail
|
|
813
|
+
const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail }).messages);
|
|
663
814
|
store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
|
|
664
|
-
return `folded:${decision.tier}:${count} msgs (~${approxTokens} tokens)`;
|
|
815
|
+
return `folded:${decision.tier}:${count} msgs (~${approxTokens} tokens)${degraded ? " fallback=mechanical" : ""}`;
|
|
665
816
|
}
|
|
666
817
|
|
|
667
818
|
/** Idle-boundary hook (also re-used by the stream-path fallback): decide
|
|
@@ -692,18 +843,22 @@ export function applyContextTrim(ctx, config) {
|
|
|
692
843
|
return;
|
|
693
844
|
}
|
|
694
845
|
inFlight.add(sessionId);
|
|
846
|
+
const cycleStartedAt = Date.now();
|
|
847
|
+
// Hoisted for the catch below: a dispose-cut throw may land before the
|
|
848
|
+
// trigger ever ran, and the catch must not touch a dead binding.
|
|
849
|
+
let decision = null;
|
|
695
850
|
try {
|
|
696
851
|
const { protectedTail, ...thresholds } = thresholdsNow();
|
|
697
|
-
const foldable = computeFoldableRange(capture.messages, store.ledgerOf(sessionId), protectedTail);
|
|
698
|
-
const windowTokens = await windowTokensFor(capture.model);
|
|
699
852
|
const ledger = store.ledgerOf(sessionId);
|
|
853
|
+
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
|
|
854
|
+
const windowTokens = await windowTokensFor(capture.model);
|
|
700
855
|
// Pressure tiers run on the REAL prompt size when the tally is known:
|
|
701
856
|
// max(view, wire). The view estimate misses the system/context
|
|
702
857
|
// sections, so a 136k wire once read as a 50k view and the tiers
|
|
703
858
|
// stayed silent. Unknown tally → exactly today's behavior.
|
|
704
859
|
const wireTokens = wireTokensOf(sessionId);
|
|
705
860
|
const pressureTokens = wireTokens !== null ? Math.max(capture.viewTokens, wireTokens) : capture.viewTokens;
|
|
706
|
-
|
|
861
|
+
decision = evaluateTrigger({
|
|
707
862
|
foldable,
|
|
708
863
|
messages: capture.messages,
|
|
709
864
|
viewTokens: pressureTokens,
|
|
@@ -714,23 +869,31 @@ export function applyContextTrim(ctx, config) {
|
|
|
714
869
|
foldsThisHour: ledger.foldsThisHour,
|
|
715
870
|
lastEmergencyAt: lastEmergencyAtBySession.get(sessionId) ?? 0,
|
|
716
871
|
ledger,
|
|
872
|
+
ladderRuns: ladderEnabledNow() ? ladderRunsOf(ledger) : { t2: [], t3: [] },
|
|
717
873
|
});
|
|
718
874
|
decisionLog(`session=${sessionId} via=${via} action=${decision.action} tier=${decision.tier ?? "-"} reason=${decision.reason || "-"} view=${Math.round(capture.viewTokens)}${wireTokens === null ? "" : ` wire=${wireTokens}`} window=${windowTokens ?? "null"}`);
|
|
719
875
|
if (decision.action === "none") return;
|
|
720
|
-
const outcome = await runFoldCycle(sessionId, decision);
|
|
876
|
+
const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt);
|
|
721
877
|
// Failure ladder: commit resets it; a summarize/commit failure
|
|
722
878
|
// climbs it (the no-backoff loop this closes — those outcomes
|
|
723
|
-
// advance no cadence gate on their own).
|
|
879
|
+
// advance no cadence gate on their own). A ladder run whose
|
|
880
|
+
// consumed blocks vanished between trigger and commit is the same
|
|
881
|
+
// family: without backoff it would re-fire at every boundary.
|
|
724
882
|
if (outcome.startsWith("folded:")) clearFoldFailure(sessionId);
|
|
725
|
-
else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:")) recordFoldFailure(sessionId);
|
|
883
|
+
else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:") || outcome === "ladder-consumed-missing") recordFoldFailure(sessionId);
|
|
726
884
|
// Outcome lands in the decision log too: warn() has no reachable
|
|
727
885
|
// sink on some hosts, and the commit/fail boundary is exactly what
|
|
728
886
|
// live diagnosis needs.
|
|
729
|
-
decisionLog(`session=${sessionId} outcome=${outcome} tier=${decision.tier}`);
|
|
887
|
+
decisionLog(`session=${sessionId} outcome=${outcome} tier=${decision.tier ?? "-"}`);
|
|
730
888
|
warn(`[context-trim] ${sessionId}: ${outcome} (${decision.reason})`);
|
|
731
889
|
} catch (error) {
|
|
732
|
-
|
|
733
|
-
|
|
890
|
+
// A dispose mid-cycle throws through here too (ctx/service teardown) —
|
|
891
|
+
// that is a reload cut, not a model failure: log it and skip the ladder.
|
|
892
|
+
if (disposedSince(cycleStartedAt)) decisionLog(`session=${sessionId} outcome=reload-aborted tier=${decision?.tier ?? "-"}`);
|
|
893
|
+
else {
|
|
894
|
+
recordFoldFailure(sessionId); // a thrown cycle counts as failed too
|
|
895
|
+
warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
|
|
896
|
+
}
|
|
734
897
|
} finally {
|
|
735
898
|
inFlight.delete(sessionId);
|
|
736
899
|
}
|
|
@@ -738,63 +901,17 @@ export function applyContextTrim(ctx, config) {
|
|
|
738
901
|
|
|
739
902
|
// ---- host registrations (each individually fail-open) --------------------
|
|
740
903
|
|
|
904
|
+
// MODEL-VISIBILITY INVARIANT: only the byte-stable protocol head is
|
|
905
|
+
// registered. The previous live context line (view tokens, % of window,
|
|
906
|
+
// folds, growth) is gone by design — water levels and progress numbers
|
|
907
|
+
// steer model judgment (bili lesson #2, docs/ladder-fold.md). Human
|
|
908
|
+
// diagnostics live in decisions.log (view-applied / decision / outcome
|
|
909
|
+
// rows) and the client badge.
|
|
741
910
|
try {
|
|
742
|
-
ctx.systemPrompt?.section?.({ name: "switchman:trim", order: 10450, interpolate: false, text: () => TRIM_PROTOCOL_TEXT });
|
|
911
|
+
ctx.systemPrompt?.section?.({ name: "switchman:trim", order: 10450, interpolate: false, text: () => (enabledNow() && modeNow() !== "off" ? TRIM_PROTOCOL_TEXT : "") });
|
|
743
912
|
} catch (error) {
|
|
744
913
|
warn(`[context-trim] systemPrompt.section failed: ${error?.message ?? error}`);
|
|
745
914
|
}
|
|
746
|
-
try {
|
|
747
|
-
ctx.systemPrompt?.context?.({
|
|
748
|
-
name: "switchman:trim",
|
|
749
|
-
order: 299,
|
|
750
|
-
required: true,
|
|
751
|
-
interpolate: false,
|
|
752
|
-
text: (context) => {
|
|
753
|
-
try {
|
|
754
|
-
const agent = context?.agent ?? null;
|
|
755
|
-
// context.agent is also the agent OBJECT — derive the id plus a
|
|
756
|
-
// display label so the banner never prints [object Object].
|
|
757
|
-
const sessionId = agentSessionIdOf(agent);
|
|
758
|
-
const label = agentLabelOf(agent);
|
|
759
|
-
const record = sessionId !== null ? metrics.of(sessionId) : null;
|
|
760
|
-
const ledger = sessionId !== null ? store.ledgerOf(sessionId) : null;
|
|
761
|
-
const model = captureModelOf(sessionId);
|
|
762
|
-
const windowTokens = model === null ? null : cachedWindowOf(model);
|
|
763
|
-
const growthBaseline = ledger !== null && Number.isFinite(ledger.lastFoldLowTokens) && ledger.lastFoldLowTokens > 0 ? ledger.lastFoldLowTokens : ledger !== null && Number.isFinite(ledger.lastFoldViewTokens) && ledger.lastFoldViewTokens > 0 ? ledger.lastFoldViewTokens : null;
|
|
764
|
-
// Honest wire status: the wire is only "raw" when NEITHER channel
|
|
765
|
-
// can reach it — a capable surface pass, or a ledger that already
|
|
766
|
-
// proves a SHIPPED replacement (a capture alone does not: it may
|
|
767
|
-
// never ship), keeps the marker off even after the in-memory
|
|
768
|
-
// recency window expired.
|
|
769
|
-
const wireRaw = recentTrimInjectOf(sessionId) === "none" && !surfaceChannelCapable(sessionId) && !ledgerShowsShippedReplacement(sessionId);
|
|
770
|
-
return renderTrimContextLine(label, {
|
|
771
|
-
enabled: enabledNow(),
|
|
772
|
-
mode: modeNow(),
|
|
773
|
-
viewTokens: record?.viewTokens ?? null,
|
|
774
|
-
windowTokens,
|
|
775
|
-
folds: Array.isArray(ledger?.blocks) ? ledger.blocks.length : 0,
|
|
776
|
-
growthTokens: record?.viewTokens !== null && record !== undefined && growthBaseline !== null ? record.viewTokens - growthBaseline : null,
|
|
777
|
-
lastModel: record?.lastModel ?? null,
|
|
778
|
-
wireRaw,
|
|
779
|
-
});
|
|
780
|
-
} catch {
|
|
781
|
-
return "";
|
|
782
|
-
}
|
|
783
|
-
},
|
|
784
|
-
});
|
|
785
|
-
} catch (error) {
|
|
786
|
-
warn(`[context-trim] systemPrompt.context failed: ${error?.message ?? error}`);
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
/** Synchronous window lookup for the banner: raw cache composed with
|
|
790
|
-
* the cap (async resolution happens in the idle path / stream path). */
|
|
791
|
-
function captureModelOf(sessionId) {
|
|
792
|
-
return captures.get(sessionId)?.model ?? null;
|
|
793
|
-
}
|
|
794
|
-
function cachedWindowOf(model) {
|
|
795
|
-
const resolved = model !== null && typeof model.provider === "string" && typeof model.model === "string" ? windowCache.get(`${model.provider}/${model.model}`)?.windowTokens ?? null : null;
|
|
796
|
-
return effectiveWindowTokens(resolved, windowCapNow());
|
|
797
|
-
}
|
|
798
915
|
|
|
799
916
|
try {
|
|
800
917
|
ctx.on("agent/status", (payload) => {
|
|
@@ -860,29 +977,56 @@ export function applyContextTrim(ctx, config) {
|
|
|
860
977
|
|
|
861
978
|
// ---- tools (L1 advisory surface) ------------------------------------------
|
|
862
979
|
|
|
980
|
+
/** Render the fold ledger for the status tool: ACTIVE blocks with true
|
|
981
|
+
* sizes, absorbed history as one line. NO water level, NO fill
|
|
982
|
+
* percentage, NO window math, NO threshold percentages — sizes of
|
|
983
|
+
* concrete ranges are the model-facing interface (bili mechanism #5);
|
|
984
|
+
* global progress numbers are not (lesson #2). */
|
|
985
|
+
function renderStatusLedger(ledger) {
|
|
986
|
+
const blocks = Array.isArray(ledger?.blocks) ? ledger.blocks : [];
|
|
987
|
+
const active = blocks.filter((block) => block?.active === true);
|
|
988
|
+
const absorbed = blocks.length - active.length;
|
|
989
|
+
const lines = [];
|
|
990
|
+
if (blocks.length === 0) {
|
|
991
|
+
lines.push("fold ledger: empty (nothing folded yet)");
|
|
992
|
+
return lines;
|
|
993
|
+
}
|
|
994
|
+
lines.push(`fold ledger: ${active.length} active block(s)${absorbed > 0 ? `, ${absorbed} absorbed by higher tiers` : ""} (oldest first)`);
|
|
995
|
+
for (const block of active) {
|
|
996
|
+
const tier = block.tier === 3 ? "T3" : block.tier === 2 ? "T2" : "T1";
|
|
997
|
+
const range = block.refRange !== null && block.refRange !== undefined ? ` refs [m${block.refRange.firstId}]..[m${block.refRange.lastId}]` : ` distills ${block.count} lower-tier block(s)`;
|
|
998
|
+
lines.push(`- ${block.id} ${tier}: ~${Math.round(Number.isFinite(block.approxTokens) ? block.approxTokens : 0)} tokens of material${range}${block.tier >= 2 ? ` — foldable into a higher tier together with its sibling blocks` : ""}`);
|
|
999
|
+
}
|
|
1000
|
+
return lines;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
863
1003
|
try {
|
|
864
1004
|
ctx.tools?.register?.({
|
|
865
1005
|
name: "ctx_trim_status",
|
|
866
|
-
description: "
|
|
1006
|
+
description: "List this session's compressible context ranges with their true sizes: the fold ledger, the raw range a fold would cover, and distillation targets. Read-only. Contains sizes only — never progress percentages.",
|
|
867
1007
|
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
868
1008
|
required: [],
|
|
869
1009
|
async execute() {
|
|
870
1010
|
const sessionId = sessionIdOf({});
|
|
871
1011
|
if (sessionId === null) return "No session context available.";
|
|
1012
|
+
if (!enabledNow()) return "Context trimming is disabled for this deployment.";
|
|
872
1013
|
const ledger = store.ledgerOf(sessionId);
|
|
873
|
-
const record = metrics.of(sessionId);
|
|
874
1014
|
const capture = captures.get(sessionId);
|
|
875
|
-
const { protectedTail
|
|
876
|
-
const lines = [
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
`view tokens (estimate): ${record?.viewTokens ?? "n/a"}`,
|
|
880
|
-
`window tokens: ${cachedWindowOf(capture?.model ?? null) ?? "unknown"}`,
|
|
881
|
-
`folds: ${Array.isArray(ledger.blocks) ? ledger.blocks.length : 0}, frontier: ${ledger.frontier ?? "none"}, last fold view tokens: ${ledger.lastFoldViewTokens || "n/a"}`,
|
|
882
|
-
`thresholds: growth>${thresholds.growthTokens} increment>=${thresholds.incrementTokens} pressure=${thresholds.pressurePercent}% emergency=${thresholds.emergencyPercent}% maxPerHour=${thresholds.maxFoldsPerHour} protectedTail=${protectedTail}`,
|
|
883
|
-
];
|
|
1015
|
+
const { protectedTail } = thresholdsNow();
|
|
1016
|
+
const lines = [`ctx_trim_status — mode: ${modeNow()} (enabled: ${enabledNow()})`];
|
|
1017
|
+
lines.push(...renderStatusLedger(ledger));
|
|
1018
|
+
// The raw range a fold would cover next, with its true size.
|
|
884
1019
|
if (capture !== undefined) {
|
|
885
|
-
|
|
1020
|
+
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
|
|
1021
|
+
if (foldable !== null) {
|
|
1022
|
+
const firstRef = refOf(capture.messages[foldable.startIdx], foldable.startIdx);
|
|
1023
|
+
const lastRef = refOf(capture.messages[foldable.endIdx], foldable.endIdx);
|
|
1024
|
+
lines.push("", `compressible raw range: [m${firstRef}]..[m${lastRef}] (${foldable.endIdx - foldable.startIdx + 1} messages, ~${estimateTokensOf(capture.messages, foldable.startIdx, foldable.endIdx)} tokens) — a ctx_trim_fold over this range replaces it with your summary.`);
|
|
1025
|
+
} else {
|
|
1026
|
+
lines.push("", "compressible raw range: none right now (everything settled is already folded).");
|
|
1027
|
+
}
|
|
1028
|
+
// Message refs stay available for fold targeting (sizes only).
|
|
1029
|
+
lines.push("", `captured history (${capture.messages.length} messages; refs [m...] are fold anchors, per-message sizes are estimates):`);
|
|
886
1030
|
capture.messages.forEach((message, i) => {
|
|
887
1031
|
lines.push(`- [m${refOf(message, i)}] (${message?.role ?? "?"}) ~${estimateTokensOf(capture.messages, i, i)} tokens`);
|
|
888
1032
|
});
|
|
@@ -914,6 +1058,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
914
1058
|
async execute(args) {
|
|
915
1059
|
const sessionId = sessionIdOf({});
|
|
916
1060
|
if (sessionId === null) return "No session context available.";
|
|
1061
|
+
if (!enabledNow() || modeNow() === "off") return "Context trimming is disabled for this deployment.";
|
|
917
1062
|
const capture = captures.get(sessionId);
|
|
918
1063
|
if (capture === undefined) return "No captured history yet — fold is unavailable until the next model call.";
|
|
919
1064
|
const positions = refPositions(capture.messages);
|
|
@@ -945,7 +1090,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
945
1090
|
approxTokens: estimateTokensOf(capture.messages, firstIdx, lastIdx),
|
|
946
1091
|
summary: String(args.summary ?? ""),
|
|
947
1092
|
kind: "summary",
|
|
948
|
-
tier:
|
|
1093
|
+
tier: 1,
|
|
949
1094
|
},
|
|
950
1095
|
positions,
|
|
951
1096
|
);
|
|
@@ -968,10 +1113,14 @@ export function applyContextTrim(ctx, config) {
|
|
|
968
1113
|
|
|
969
1114
|
function streamListener(options, next) {
|
|
970
1115
|
let viewTokens = null;
|
|
1116
|
+
let mainDispatch = false;
|
|
971
1117
|
try {
|
|
972
1118
|
if (enabledNow()) {
|
|
973
1119
|
const applied = applyViewToOptions(options);
|
|
974
|
-
if (applied !== null)
|
|
1120
|
+
if (applied !== null) {
|
|
1121
|
+
viewTokens = applied.viewTokens;
|
|
1122
|
+
mainDispatch = applied.sideDispatch !== true;
|
|
1123
|
+
}
|
|
975
1124
|
}
|
|
976
1125
|
} catch (error) {
|
|
977
1126
|
decisionLog(`view-build-failed why=${error?.message ?? error}`);
|
|
@@ -985,7 +1134,10 @@ export function applyContextTrim(ctx, config) {
|
|
|
985
1134
|
if (wrappedSession === null) return result;
|
|
986
1135
|
return wrapStreamForMetrics(result, (chunk) => {
|
|
987
1136
|
const usage = extractUsage(chunk);
|
|
988
|
-
if (usage !== null)
|
|
1137
|
+
if (usage !== null) {
|
|
1138
|
+
metrics.recordUsage(wrappedSession, usage);
|
|
1139
|
+
if (mainDispatch) noteTrimWireTokens(wrappedSession, usage);
|
|
1140
|
+
}
|
|
989
1141
|
});
|
|
990
1142
|
} catch (error) {
|
|
991
1143
|
warn(`[context-trim] stream wrap failed: ${error?.message ?? error}`);
|
|
@@ -994,14 +1146,19 @@ export function applyContextTrim(ctx, config) {
|
|
|
994
1146
|
// Fallback fold check on the stream path: the agent/status idle event
|
|
995
1147
|
// may not reach a globally-registered listener on every host topology,
|
|
996
1148
|
// so each stream end also runs a throttled (60 s/session) check. The
|
|
997
|
-
// trigger's own cadence gates still guard over-folding.
|
|
1149
|
+
// trigger's own cadence gates still guard over-folding. After the fold
|
|
1150
|
+
// check, chain a surface sweep: fold blocks commit mid-run even for
|
|
1151
|
+
// sessions whose dispatches are host-frozen (subagent children see
|
|
1152
|
+
// exactly ONE idle — at task end — so without this their wire ships
|
|
1153
|
+
// raw history until they finish; the sweep is the only relief channel
|
|
1154
|
+
// that reaches frozen dispatches). Same 60 s cadence, fail-open.
|
|
998
1155
|
try {
|
|
999
1156
|
if (enabledNow() && modeNow() === "auto" && wrappedSession !== null) {
|
|
1000
1157
|
const nowMs = Date.now();
|
|
1001
1158
|
if (nowMs - (lastStreamCheckAt.get(wrappedSession) ?? 0) >= 60_000) {
|
|
1002
1159
|
lastStreamCheckAt.set(wrappedSession, nowMs);
|
|
1003
1160
|
if (lastStreamCheckAt.size > 200) lastStreamCheckAt.delete(lastStreamCheckAt.keys().next().value);
|
|
1004
|
-
void maybeFold(wrappedSession, "stream");
|
|
1161
|
+
void maybeFold(wrappedSession, "stream").finally(() => surfacePass.sweep(wrappedSession, "stream"));
|
|
1005
1162
|
}
|
|
1006
1163
|
}
|
|
1007
1164
|
} catch {
|
|
@@ -1050,5 +1207,5 @@ export function applyContextTrim(ctx, config) {
|
|
|
1050
1207
|
warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
|
|
1051
1208
|
}
|
|
1052
1209
|
|
|
1053
|
-
return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass
|
|
1210
|
+
return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
|
|
1054
1211
|
}
|