dsh-switchman 1.11.5 → 1.11.6
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 +4 -2
- package/README.es.md +4 -2
- package/README.fr.md +4 -2
- package/README.it.md +4 -2
- package/README.ja.md +4 -2
- package/README.ko.md +4 -2
- package/README.md +5 -3
- package/README.pt.md +4 -2
- package/README.ru.md +4 -2
- package/README.zh-TW.md +4 -2
- package/README.zh.md +4 -2
- package/client.js +92 -11
- package/cordis.patch.yml +17 -1
- package/host/config.js +14 -0
- package/host/context-trim/index.js +294 -18
- package/host/context-trim/surface.js +213 -6
- package/host/context-trim/trigger.js +29 -12
- package/host/context-trim/view.js +17 -2
- package/host/context-watch.js +5 -1
- package/host/routes.js +3 -0
- package/package.json +1 -1
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Config fields (host/config.js, all volatile): trimEnabled, trimMode
|
|
26
26
|
* ("off"|"advisory"|"auto"), trimGrowthTokens, trimIncrementTokens,
|
|
27
|
-
* trimPressurePercent, trimEmergencyPercent,
|
|
27
|
+
* trimPressurePercent, trimEmergencyPercent, trimAdvisoryPercent,
|
|
28
|
+
* trimAdvisoryCriticalPercent, trimWindowTokens,
|
|
28
29
|
* trimProtectedTail, trimMaxFoldsPerHour, trimSideModel, trimFoldFallbackModel, trimStateDir,
|
|
29
30
|
* trimLadderEnabled, trimLadderT2Tokens, trimLadderT2MinBlocks,
|
|
30
31
|
* trimLadderT3Tokens, trimLadderT3MinBlocks.
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
|
|
33
34
|
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
34
35
|
import { join } from "node:path";
|
|
35
|
-
import { buildView, estimateTokensOf, injectView, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
|
|
36
|
+
import { buildView, estimateTokensOf, injectView, isFoldSummaryMessage, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
|
|
36
37
|
import { computeFoldableRange, evaluateTrigger, EMERGENCY_COOLDOWN_MS } from "./trigger.js";
|
|
37
38
|
import { LADDER_SYSTEM_PROMPT, mechanicalSummary, renderLadderMaterial, renderMaterial, summarizeWithModel } from "./summarizer.js";
|
|
38
39
|
import { createStore } from "./state.js";
|
|
@@ -274,6 +275,44 @@ export const TRIM_PROTOCOL_TEXT = [
|
|
|
274
275
|
"- Tool-call pairs (an assistant tool call and its results) must always be folded together or not at all.",
|
|
275
276
|
].join("\n");
|
|
276
277
|
|
|
278
|
+
// Advisory nudge thresholds: the default speak line (65%) deliberately sits
|
|
279
|
+
// below the protocol's own 80% fold line so the model is nudged while it
|
|
280
|
+
// still has comfortable headroom; critical wording escalates at 95%. At most
|
|
281
|
+
// one nudge per session per 10 minutes (an instant re-nudge when the level
|
|
282
|
+
// band changes). Both lines are config-tunable via trimAdvisoryPercent /
|
|
283
|
+
// trimAdvisoryCriticalPercent.
|
|
284
|
+
export const ADVISORY_NUDGE_PERCENT = 65;
|
|
285
|
+
export const ADVISORY_NUDGE_CRITICAL_PERCENT = 95;
|
|
286
|
+
export const ADVISORY_NUDGE_REPEAT_MS = 600_000;
|
|
287
|
+
|
|
288
|
+
/** Effective advisory lines for one dispatch: config-tunable, falling back to
|
|
289
|
+
* the configured defaults (65/95) when absent, inverted, or out of range —
|
|
290
|
+
* a broken pair never silences the nudge. */
|
|
291
|
+
function advisoryNudgeLines(config) {
|
|
292
|
+
const line = readNumber(config, "trimAdvisoryPercent", ADVISORY_NUDGE_PERCENT);
|
|
293
|
+
const critical = readNumber(config, "trimAdvisoryCriticalPercent", ADVISORY_NUDGE_CRITICAL_PERCENT);
|
|
294
|
+
const sane = (v) => Number.isFinite(v) && v > 0 && v <= 100;
|
|
295
|
+
if (!sane(line) || !sane(critical) || line >= critical) return { line: ADVISORY_NUDGE_PERCENT, critical: ADVISORY_NUDGE_CRITICAL_PERCENT };
|
|
296
|
+
return { line, critical };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Advisory-mode nudge text. Deliberate, bounded exception to the
|
|
300
|
+
* digit-free disclosure policy of TRIM_PROTOCOL_TEXT above: advisory mode has
|
|
301
|
+
* NO engine-initiated folds (the modeNow gate), so the model's own fold is
|
|
302
|
+
* the only relief channel. At threshold, the live-number instruction is
|
|
303
|
+
* appended to the session surface tail, not injected into the registered
|
|
304
|
+
* system section. */
|
|
305
|
+
export function advisoryNudgeText(pressurePercent, critical, foldLine = ADVISORY_NUDGE_PERCENT, criticalLine = ADVISORY_NUDGE_CRITICAL_PERCENT) {
|
|
306
|
+
const action = critical
|
|
307
|
+
? `Pressure is CRITICAL (${pressurePercent}% ≥ ${criticalLine}%): fold down to under ~50% of the context IN THIS TURN.`
|
|
308
|
+
: `At ${pressurePercent}% (fold line ${foldLine}%): ${foldLine}–${foldLine + 9}% → fold roughly half of the older history now; ≥${foldLine + 10}% → fold down to under ~50%.`;
|
|
309
|
+
return [
|
|
310
|
+
'[SWITCHMAN:TRIM] Advisory nudge — pinned to the session tail until pressure falls below the fold line.',
|
|
311
|
+
`Live context pressure on this session has reached the fold line. ${action}`,
|
|
312
|
+
"Act now: call ctx_trim_status to list compressible ranges, pick one contiguous range, and fold it with ctx_trim_fold (tool-call pairs fold together or not at all; existing summaries stay). Do not wait for a routine moment.",
|
|
313
|
+
].join("\n");
|
|
314
|
+
}
|
|
315
|
+
|
|
277
316
|
/** Effective window for the pressure/emergency tier math:
|
|
278
317
|
* min(model-resolved window, configured cap). Null only when BOTH inputs
|
|
279
318
|
* are unusable — the trigger then skips those tiers (fail-open). */
|
|
@@ -359,8 +398,158 @@ export function applyContextTrim(ctx, config) {
|
|
|
359
398
|
return null;
|
|
360
399
|
}
|
|
361
400
|
};
|
|
401
|
+
|
|
402
|
+
// Advisory nudge state: sessionId -> { at, critical, seq }. Bounded like the
|
|
403
|
+
// other per-session registries (200 entries).
|
|
404
|
+
const advisoryNudgeBySession = new Map();
|
|
405
|
+
|
|
406
|
+
// Single-flight for nudge warm-ups: during resolveRawWindow's <=10s window
|
|
407
|
+
// every main dispatch would otherwise re-fire the same resolve. Entries
|
|
408
|
+
// self-delete on settle; the expiry is only a belt-and-suspenders bound.
|
|
409
|
+
const windowWarmupInFlight = new Map(); // "provider/model" -> expiresAt
|
|
410
|
+
function warmWindowOnce(model, now) {
|
|
411
|
+
const key = `${model.provider}/${model.model}`;
|
|
412
|
+
const until = windowWarmupInFlight.get(key);
|
|
413
|
+
if (until !== undefined && now < until) return;
|
|
414
|
+
windowWarmupInFlight.set(key, now + 15_000); // > resolveRawWindow's 10s ceiling
|
|
415
|
+
void windowTokensFor(model).catch(() => {}).finally(() => {
|
|
416
|
+
if (windowWarmupInFlight.get(key) === until) windowWarmupInFlight.delete(key);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Advisory-mode pressure nudge (text: advisoryNudgeText). Appends the live
|
|
421
|
+
* instruction to the session surface tail once pressure crosses the fold
|
|
422
|
+
* line. Pressure reuses the meter-duality bridge (wire preferred, view
|
|
423
|
+
* estimate as floor) against the session model's window read from the SYNC
|
|
424
|
+
* cache — a cache miss fires an async warm-up (windowTokensFor) and defers
|
|
425
|
+
* the nudge to a later dispatch instead of blocking this one — and it only
|
|
426
|
+
* reaches the wire on the NEXT dispatch (the current request is already
|
|
427
|
+
* assembled). The speak is a pure suffix, so the prompt prefix stays
|
|
428
|
+
* untouched; a later clear or upgrade prunes only the nudge nodes, so
|
|
429
|
+
* cache reuse survives up to the nudge instead of invalidating the whole
|
|
430
|
+
* prefix as the old system-head splice did. */
|
|
431
|
+
function deliverAdvisoryNudge(sessionId, viewTokens) {
|
|
432
|
+
if (modeNow() !== "advisory") return;
|
|
433
|
+
const model = capturedModelBySession.get(sessionId) ?? null;
|
|
434
|
+
if (model === null) {
|
|
435
|
+
decisionLog("advisory-nudge skip why=no-captured-model");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (typeof model.provider !== "string" || typeof model.model !== "string") {
|
|
439
|
+
decisionLog("advisory-nudge skip why=bad-model-shape");
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const cached = windowCache.get(model.provider + "/" + model.model);
|
|
443
|
+
let resolvedWindow; // model-resolved window, or the configured cap when unknown
|
|
444
|
+
if (cached === undefined) {
|
|
445
|
+
// Unknown: warm up single-flight but stay AUDIBLE — fall back to the
|
|
446
|
+
// configured cap (the same denominator the emergency tier uses), so a
|
|
447
|
+
// model whose metadata never resolves still gets nudged at the fold
|
|
448
|
+
// line instead of burning to force unseen (2026-11-14: window-unknown
|
|
449
|
+
// swallowed every nudge at 89% pressure).
|
|
450
|
+
warmWindowOnce(model, Date.now());
|
|
451
|
+
resolvedWindow = windowCapNow();
|
|
452
|
+
} else {
|
|
453
|
+
// Stale-but-known still gates correctly: effectiveWindowTokens binds to the
|
|
454
|
+
// configured cap for every model larger than it, so the pressure math is
|
|
455
|
+
// unchanged. Re-warm in the background instead of burning this boundary.
|
|
456
|
+
if (Date.now() - cached.at >= 600_000) warmWindowOnce(model, Date.now());
|
|
457
|
+
// Resolution failed inside the TTL: same cap fallback instead of going
|
|
458
|
+
// quiet (no retry hammering — the warm-up stays single-flight).
|
|
459
|
+
resolvedWindow = cached.windowTokens ?? windowCapNow();
|
|
460
|
+
}
|
|
461
|
+
// Bind the resolved window to the configured cap before computing pressure.
|
|
462
|
+
const windowTokens = effectiveWindowTokens(resolvedWindow, windowCapNow());
|
|
463
|
+
if (windowTokens === null) {
|
|
464
|
+
// Cap unavailable too (wmForceTokens=0 and no explicit cap): nothing to
|
|
465
|
+
// measure against — the one remaining legitimate silence.
|
|
466
|
+
decisionLog("advisory-nudge skip why=window-unknown");
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const wire = wireTokensOf(sessionId);
|
|
470
|
+
const mass = Math.max(Number.isFinite(wire) ? wire : 0, Number.isFinite(viewTokens) ? viewTokens : 0);
|
|
471
|
+
if (mass <= 0) return;
|
|
472
|
+
const pressurePercent = (mass / windowTokens) * 100;
|
|
473
|
+
const nudgeLines = advisoryNudgeLines(config);
|
|
474
|
+
const prior = advisoryNudgeBySession.get(sessionId);
|
|
475
|
+
if (pressurePercent < nudgeLines.line) {
|
|
476
|
+
// Below the line every live nudge must go. The sweep is NOT gated
|
|
477
|
+
// on `prior`: restarts and registry evictions lose the episode while
|
|
478
|
+
// the node stays visible. The recorded seq joins the union even when
|
|
479
|
+
// it fell out of the 50-node scan window; nudgePrune skips seqs a
|
|
480
|
+
// fold already consumed, so a vanished prior.seq cannot wedge this.
|
|
481
|
+
const seqs = new Set(surfacePass.nudgeScan(sessionId));
|
|
482
|
+
if (Number.isFinite(prior?.seq)) seqs.add(prior.seq);
|
|
483
|
+
if (seqs.size > 0) {
|
|
484
|
+
if (!surfacePass.nudgePrune(sessionId, [...seqs])) {
|
|
485
|
+
decisionLog("advisory-nudge skip why=prune-failed pressure=" + Math.round(pressurePercent) + "%");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
advisoryNudgeBySession.delete(sessionId);
|
|
489
|
+
decisionLog("advisory-nudge below-line pressure=" + Math.round(pressurePercent) + "% mass=" + Math.round(mass) + " resolved=" + cached.windowTokens + " cap=" + windowCapNow() + " pruned=" + seqs.size);
|
|
490
|
+
} else if (prior !== undefined) {
|
|
491
|
+
advisoryNudgeBySession.delete(sessionId); // nothing visible left; close the episode quietly
|
|
492
|
+
}
|
|
493
|
+
return; // fresh episode on the next crossing
|
|
494
|
+
}
|
|
495
|
+
const critical = pressurePercent >= nudgeLines.critical;
|
|
496
|
+
const now = Date.now();
|
|
497
|
+
// Any band CHANGE re-speaks immediately: escalation high->critical, and
|
|
498
|
+
// intentionally also a downgrade back into high after a partial fold.
|
|
499
|
+
if (prior !== undefined && prior.critical === critical && now - prior.at < ADVISORY_NUDGE_REPEAT_MS) return;
|
|
500
|
+
if (!surfacePass.nudgeSupported(sessionId)) {
|
|
501
|
+
decisionLog("advisory-nudge skip why=surface-unavailable pressure=" + Math.round(pressurePercent) + "%");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Replace the recorded prior nudge, or clear orphans a restart left
|
|
506
|
+
// behind, before speaking fresh. nudgePrune only touches seqs still
|
|
507
|
+
// live on the surface, so a prior a fold already consumed is a no-op.
|
|
508
|
+
const priorSeq = Number.isFinite(prior?.seq) ? prior.seq : null;
|
|
509
|
+
if (priorSeq !== null) {
|
|
510
|
+
if (!surfacePass.nudgePrune(sessionId, [priorSeq])) {
|
|
511
|
+
decisionLog("advisory-nudge skip why=prune-failed pressure=" + Math.round(pressurePercent) + "%");
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
} else {
|
|
515
|
+
const orphans = surfacePass.nudgeScan(sessionId);
|
|
516
|
+
if (orphans.length > 0) {
|
|
517
|
+
if (!surfacePass.nudgePrune(sessionId, orphans)) {
|
|
518
|
+
decisionLog("advisory-nudge skip why=prune-failed pressure=" + Math.round(pressurePercent) + "%");
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
decisionLog("advisory-nudge orphan-pruned seqs=" + orphans.length + " pressure=" + Math.round(pressurePercent) + "%");
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const nudge = advisoryNudgeText(Math.round(pressurePercent), critical, nudgeLines.line, nudgeLines.critical);
|
|
525
|
+
const appended = surfacePass.nudgeAppend(sessionId, nudge);
|
|
526
|
+
if (appended.error !== null || !Number.isFinite(appended.seq)) {
|
|
527
|
+
advisoryNudgeBySession.delete(sessionId); // a pruned prior must not throttle the retry
|
|
528
|
+
decisionLog("advisory-nudge skip why=append-failed err=" + (appended.error ?? "unknown") + " pressure=" + Math.round(pressurePercent) + "%");
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const seq = appended.seq;
|
|
532
|
+
advisoryNudgeBySession.delete(sessionId); // re-insert: refresh recency so eviction drops the least recently nudged
|
|
533
|
+
advisoryNudgeBySession.set(sessionId, { at: now, critical, seq });
|
|
534
|
+
if (advisoryNudgeBySession.size > 200) advisoryNudgeBySession.delete(advisoryNudgeBySession.keys().next().value);
|
|
535
|
+
decisionLog("session=" + sessionId + " advisory-nudge pressure=" + Math.round(pressurePercent) + "% mass=" + Math.round(mass) + " window=" + windowTokens + " level=" + (critical ? "critical" : "high") + " seq=" + seq);
|
|
536
|
+
}
|
|
537
|
+
|
|
362
538
|
const captures = new Map(); // sessionId -> { messages, refs, viewTokens, model, at }
|
|
363
|
-
|
|
539
|
+
// sessionId -> generation token of the running fold cycle. A plain Set
|
|
540
|
+
// pinned a session forever once a cycle's await never settled; the
|
|
541
|
+
// maybeFold watchdog swaps the token so wedged cycles release the lock.
|
|
542
|
+
const inFlight = new Map();
|
|
543
|
+
// Fold-cycle watchdog: a cycle still "in flight" after this long logs a
|
|
544
|
+
// cycle-stuck row, releases the lock, and lets the next trigger retry.
|
|
545
|
+
// The ceiling must exceed the worst LEGITIMATE cycle — a raw fold can
|
|
546
|
+
// summarize on side AND fallback routes (2 x SUMMARY_TIMEOUT_MS = 240s) —
|
|
547
|
+
// so a healthy slow cycle is never preempted.
|
|
548
|
+
const CYCLE_WATCHDOG_MS = 300_000;
|
|
549
|
+
// Hard ceiling for one host model-info lookup: some llm builds never
|
|
550
|
+
// settle resolveModelInfo for unresolvable routes, and that wedged
|
|
551
|
+
// await used to pin the whole fold cycle until restart.
|
|
552
|
+
const WINDOW_RESOLVE_TIMEOUT_MS = 10_000;
|
|
364
553
|
const windowCache = new Map(); // "provider/model" -> { windowTokens, at }
|
|
365
554
|
const lastEmergencyAtBySession = new Map(); // sessionId -> epoch ms of last emergency fold (per-session cooldown)
|
|
366
555
|
const lastStreamCheckAt = new Map(); // sessionId -> epoch ms of last stream-path fold check (throttle)
|
|
@@ -505,6 +694,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
505
694
|
ladderT2MinBlocks: readNumber(config, "trimLadderT2MinBlocks", 3),
|
|
506
695
|
ladderT3Tokens: readNumber(config, "trimLadderT3Tokens", 6_000),
|
|
507
696
|
ladderT3MinBlocks: readNumber(config, "trimLadderT3MinBlocks", 2),
|
|
697
|
+
protectTaskIntent: readBool(config, "trimProtectTaskIntent", true),
|
|
508
698
|
});
|
|
509
699
|
const ladderEnabledNow = () => readBool(config, "trimLadderEnabled", true);
|
|
510
700
|
/** CJK-aware summary-mass estimator for the ladder census (same units
|
|
@@ -573,20 +763,41 @@ export function applyContextTrim(ctx, config) {
|
|
|
573
763
|
return effectiveWindowTokens(resolved, windowCapNow());
|
|
574
764
|
}
|
|
575
765
|
|
|
576
|
-
/** Async raw-window resolution (fills the cache; null when unknown).
|
|
766
|
+
/** Async raw-window resolution (fills the cache; null when unknown).
|
|
767
|
+
* Two fixes over the historical behavior: the host contract is
|
|
768
|
+
* resolveModelInfo(provider, model) — two strings; passing the model
|
|
769
|
+
* object threw NO_ADAPTER on every route, so every decision row ran on
|
|
770
|
+
* the cap fallback. And the lookup is raced against a hard timeout:
|
|
771
|
+
* a never-settling promise must not pin the fold cycle. */
|
|
577
772
|
async function resolveRawWindow(model, key, now) {
|
|
773
|
+
let timer = null;
|
|
578
774
|
try {
|
|
579
775
|
const llm = ctx.get("llm", false);
|
|
580
|
-
const
|
|
776
|
+
const lookup = Promise.resolve(llm?.resolveModelInfo?.(model.provider, model.model));
|
|
777
|
+
const info = await Promise.race([
|
|
778
|
+
lookup,
|
|
779
|
+
new Promise((_, reject) => {
|
|
780
|
+
timer = setTimeout(() => reject(new Error(`no settle within ${WINDOW_RESOLVE_TIMEOUT_MS}ms`)), WINDOW_RESOLVE_TIMEOUT_MS);
|
|
781
|
+
timer.unref?.();
|
|
782
|
+
}),
|
|
783
|
+
]);
|
|
581
784
|
const windowTokens = info?.context?.contextWindow;
|
|
582
785
|
const value = Number.isFinite(windowTokens) && windowTokens > 0 ? windowTokens : null;
|
|
786
|
+
if (value === null) decisionLog(`window-resolve-failed key=${key} why=no context metadata`);
|
|
583
787
|
windowCache.set(key, { windowTokens: value, at: now });
|
|
584
788
|
if (windowCache.size > 200) windowCache.delete(windowCache.keys().next().value);
|
|
789
|
+
if (value !== null) decisionLog(`window-resolved key=${key} window=${value}`);
|
|
585
790
|
return value;
|
|
586
|
-
} catch {
|
|
791
|
+
} catch (error) {
|
|
587
792
|
windowCache.set(key, { windowTokens: null, at: now });
|
|
588
793
|
if (windowCache.size > 200) windowCache.delete(windowCache.keys().next().value);
|
|
794
|
+
// One row per model per 10-min cache window (plus rare concurrent
|
|
795
|
+
// misses inside the 10s race window): a silently failing resolution
|
|
796
|
+
// is what hid the wrong-arity call for months.
|
|
797
|
+
decisionLog(`window-resolve-failed key=${key} why=${error?.message ?? error}`);
|
|
589
798
|
return null;
|
|
799
|
+
} finally {
|
|
800
|
+
if (timer !== null) clearTimeout(timer);
|
|
590
801
|
}
|
|
591
802
|
}
|
|
592
803
|
|
|
@@ -595,7 +806,8 @@ export function applyContextTrim(ctx, config) {
|
|
|
595
806
|
function captureOf(sessionId, messages, model, viewTokens) {
|
|
596
807
|
captures.set(sessionId, { messages: messages.slice(), model, viewTokens, at: Date.now() });
|
|
597
808
|
if (model !== null && typeof model === "object" && typeof model.provider === "string" && typeof model.model === "string") {
|
|
598
|
-
|
|
809
|
+
capturedModelBySession.set(sessionId, { provider: model.provider, model: model.model, at: Date.now() });
|
|
810
|
+
if (!windowCache.has(model.provider + "/" + model.model)) warmWindowOnce(model, Date.now());
|
|
599
811
|
if (capturedModelBySession.size > 200) capturedModelBySession.delete(capturedModelBySession.keys().next().value);
|
|
600
812
|
}
|
|
601
813
|
// keep the capture map bounded (200 sessions)
|
|
@@ -744,7 +956,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
744
956
|
|
|
745
957
|
/** Run one fold cycle for a session (called at the idle boundary ONLY).
|
|
746
958
|
* Returns a short outcome string for logging. */
|
|
747
|
-
async function runFoldCycle(sessionId, decision, cycleStartedAt) {
|
|
959
|
+
async function runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken) {
|
|
748
960
|
const capture = captures.get(sessionId);
|
|
749
961
|
if (capture === undefined) return "no-capture";
|
|
750
962
|
const { protectedTail } = thresholdsNow();
|
|
@@ -770,6 +982,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
770
982
|
// Ladder folds never degrade to mechanical (docs/ladder-fold.md):
|
|
771
983
|
// after both routes fail the cycle stays fail-open with backoff.
|
|
772
984
|
if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
|
|
985
|
+
if (inFlight.get(sessionId) !== cycleToken) return "superseded"; // watchdog released us; a retry owns the session
|
|
773
986
|
const result = store.commitFold(
|
|
774
987
|
sessionId,
|
|
775
988
|
{ consumes: decision.consumes.slice(), summary, kind: "summary", tier: decision.tier === "ladder-3" ? 3 : 2, approxTokens },
|
|
@@ -828,6 +1041,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
828
1041
|
if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
|
|
829
1042
|
}
|
|
830
1043
|
const positions = refPositions(capture.messages);
|
|
1044
|
+
if (inFlight.get(sessionId) !== cycleToken) return "superseded"; // watchdog released us; a retry owns the session
|
|
831
1045
|
const result = store.commitFold(
|
|
832
1046
|
sessionId,
|
|
833
1047
|
{ refRange: { firstId: refs[0], lastId: refs[refs.length - 1] }, count, approxTokens, summary, kind, tier: decision.tier },
|
|
@@ -874,15 +1088,28 @@ export function applyContextTrim(ctx, config) {
|
|
|
874
1088
|
decisionLog(`session=${sessionId} via=${via} action=none reason=injection-unavailable view=${Math.round(capture.viewTokens)}`);
|
|
875
1089
|
return;
|
|
876
1090
|
}
|
|
877
|
-
inFlight.add(sessionId);
|
|
878
1091
|
const cycleStartedAt = Date.now();
|
|
1092
|
+
// Generation token lock (was a plain Set): a cycle wedged on an await
|
|
1093
|
+
// that never settles used to pin this session's fold path until
|
|
1094
|
+
// restart. The watchdog logs the wedge and releases the lock; an
|
|
1095
|
+
// abandoned cycle returns "superseded" at its commit guard, and its
|
|
1096
|
+
// finally can no longer delete a retry's entry.
|
|
1097
|
+
const cycleToken = {};
|
|
1098
|
+
inFlight.set(sessionId, cycleToken);
|
|
1099
|
+
const cycleWatchdog = setTimeout(() => {
|
|
1100
|
+
if (inFlight.get(sessionId) !== cycleToken) return;
|
|
1101
|
+
inFlight.delete(sessionId);
|
|
1102
|
+
decisionLog(`session=${sessionId} via=${via} outcome=cycle-stuck elapsedMs=${Date.now() - cycleStartedAt}`);
|
|
1103
|
+
warn(`[context-trim] ${sessionId}: fold cycle stuck >${CYCLE_WATCHDOG_MS}ms — lock released for the next trigger`);
|
|
1104
|
+
}, CYCLE_WATCHDOG_MS);
|
|
1105
|
+
cycleWatchdog.unref?.();
|
|
879
1106
|
// Hoisted for the catch below: a dispose-cut throw may land before the
|
|
880
1107
|
// trigger ever ran, and the catch must not touch a dead binding.
|
|
881
1108
|
let decision = null;
|
|
882
1109
|
try {
|
|
883
|
-
const { protectedTail, ...thresholds } = thresholdsNow();
|
|
1110
|
+
const { protectedTail, protectTaskIntent, ...thresholds } = thresholdsNow();
|
|
884
1111
|
const ledger = store.ledgerOf(sessionId);
|
|
885
|
-
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
|
|
1112
|
+
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
|
|
886
1113
|
const windowTokens = await windowTokensFor(capture.model);
|
|
887
1114
|
// Pressure tiers run on the REAL prompt size when the tally is known:
|
|
888
1115
|
// max(view, wire). The view estimate misses the system/context
|
|
@@ -905,12 +1132,15 @@ export function applyContextTrim(ctx, config) {
|
|
|
905
1132
|
});
|
|
906
1133
|
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"}`);
|
|
907
1134
|
if (decision.action === "none") return;
|
|
908
|
-
const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt);
|
|
1135
|
+
const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken);
|
|
909
1136
|
// Failure ladder: commit resets it; a summarize/commit failure
|
|
910
1137
|
// climbs it (the no-backoff loop this closes — those outcomes
|
|
911
1138
|
// advance no cadence gate on their own). A ladder run whose
|
|
912
1139
|
// consumed blocks vanished between trigger and commit is the same
|
|
913
1140
|
// family: without backoff it would re-fire at every boundary.
|
|
1141
|
+
// "superseded" (watchdog released the lock mid-cycle) and neutral
|
|
1142
|
+
// skips advance no ladder either — the retry that owns the session
|
|
1143
|
+
// is judged alone.
|
|
914
1144
|
if (outcome.startsWith("folded:")) clearFoldFailure(sessionId);
|
|
915
1145
|
else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:") || outcome === "ladder-consumed-missing") recordFoldFailure(sessionId);
|
|
916
1146
|
// Outcome lands in the decision log too: warn() has no reachable
|
|
@@ -927,7 +1157,10 @@ export function applyContextTrim(ctx, config) {
|
|
|
927
1157
|
warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
|
|
928
1158
|
}
|
|
929
1159
|
} finally {
|
|
930
|
-
|
|
1160
|
+
clearTimeout(cycleWatchdog);
|
|
1161
|
+
// Only the cycle owning the CURRENT token may release the lock —
|
|
1162
|
+
// a watchdog-superseded wedged cycle must not delete a retry's entry.
|
|
1163
|
+
if (inFlight.get(sessionId) === cycleToken) inFlight.delete(sessionId);
|
|
931
1164
|
}
|
|
932
1165
|
}
|
|
933
1166
|
|
|
@@ -1037,19 +1270,27 @@ export function applyContextTrim(ctx, config) {
|
|
|
1037
1270
|
name: "ctx_trim_status",
|
|
1038
1271
|
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.",
|
|
1039
1272
|
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
1040
|
-
|
|
1273
|
+
// Register-level shape: bare ctx.tools.register runs no defineTool DSL
|
|
1274
|
+
// compilation and REJECTS a definition without an output block — the
|
|
1275
|
+
// missing block silently killed both tools in every session (native
|
|
1276
|
+
// and PTC). The execute bodies return plain strings, rendered as one
|
|
1277
|
+
// text block.
|
|
1278
|
+
output: {
|
|
1279
|
+
schema: { type: "string" },
|
|
1280
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
1281
|
+
},
|
|
1041
1282
|
async execute() {
|
|
1042
1283
|
const sessionId = sessionIdOf({});
|
|
1043
1284
|
if (sessionId === null) return "No session context available.";
|
|
1044
1285
|
if (!enabledNow()) return "Context trimming is disabled for this deployment.";
|
|
1045
1286
|
const ledger = store.ledgerOf(sessionId);
|
|
1046
1287
|
const capture = captures.get(sessionId);
|
|
1047
|
-
const { protectedTail } = thresholdsNow();
|
|
1288
|
+
const { protectedTail, protectTaskIntent } = thresholdsNow();
|
|
1048
1289
|
const lines = [`ctx_trim_status — mode: ${modeNow()} (enabled: ${enabledNow()})`];
|
|
1049
1290
|
lines.push(...renderStatusLedger(ledger));
|
|
1050
1291
|
// The raw range a fold would cover next, with its true size.
|
|
1051
1292
|
if (capture !== undefined) {
|
|
1052
|
-
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
|
|
1293
|
+
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
|
|
1053
1294
|
if (foldable !== null) {
|
|
1054
1295
|
const firstRef = refOf(capture.messages[foldable.startIdx], foldable.startIdx);
|
|
1055
1296
|
const lastRef = refOf(capture.messages[foldable.endIdx], foldable.endIdx);
|
|
@@ -1087,6 +1328,11 @@ export function applyContextTrim(ctx, config) {
|
|
|
1087
1328
|
required: ["firstId", "lastId", "count", "summary"],
|
|
1088
1329
|
additionalProperties: false,
|
|
1089
1330
|
},
|
|
1331
|
+
// Register-level output block — see ctx_trim_status above.
|
|
1332
|
+
output: {
|
|
1333
|
+
schema: { type: "string" },
|
|
1334
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
1335
|
+
},
|
|
1090
1336
|
async execute(args) {
|
|
1091
1337
|
const sessionId = sessionIdOf({});
|
|
1092
1338
|
if (sessionId === null) return "No session context available.";
|
|
@@ -1110,6 +1356,15 @@ export function applyContextTrim(ctx, config) {
|
|
|
1110
1356
|
}
|
|
1111
1357
|
if (firstIdx < 0) { firstIdx = firstList[0]; lastIdx = lastList[lastList.length - 1]; }
|
|
1112
1358
|
if (lastIdx < firstIdx) return "lastId sorts before firstId in the current history.";
|
|
1359
|
+
// Prior fold summaries are never raw-fold material: a T1 fold
|
|
1360
|
+
// covering an acp_summary_* node would shadow the replacement
|
|
1361
|
+
// node and break the ladder's consumed-run bookkeeping.
|
|
1362
|
+
// Summaries consolidate only through ladder T2/T3 distillation.
|
|
1363
|
+
for (let i = firstIdx; i <= lastIdx; i++) {
|
|
1364
|
+
if (isFoldSummaryMessage(capture.messages[i])) {
|
|
1365
|
+
return "Range covers a previous fold's summary (acp_summary_*) — summaries are preserved and consolidate via T2/T3 ladder; pick a range that excludes them.";
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1113
1368
|
const { protectedTail } = thresholdsNow();
|
|
1114
1369
|
if (lastIdx >= capture.messages.length - Math.max(0, protectedTail)) return `Range reaches into the protected tail (last ${protectedTail} messages are never folded).`;
|
|
1115
1370
|
if (!rangeIsSegmentAligned(capture.messages, firstIdx, lastIdx)) return "Range splits a tool-call pair — fold whole assistant/tool segments (extend firstId/lastId to the segment boundaries).";
|
|
@@ -1134,7 +1389,18 @@ export function applyContextTrim(ctx, config) {
|
|
|
1134
1389
|
const lowLedger = store.ledgerOf(sessionId);
|
|
1135
1390
|
const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
|
|
1136
1391
|
store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
|
|
1137
|
-
|
|
1392
|
+
// Ship the fresh block onto the session surface NOW instead of waiting for
|
|
1393
|
+
// the next idle/turn-stopping boundary: a fold committed mid-turn used to
|
|
1394
|
+
// stay meter-only for the rest of the turn (2026-11-14 live test: two
|
|
1395
|
+
// tool-path folds left the wire carrying raw history for ~11 min at 97%
|
|
1396
|
+
// pressure — injected=none on frozen dispatches, both blocks pending).
|
|
1397
|
+
// The sweep's own guards (enabled flag, capable session, compaction lock,
|
|
1398
|
+
// per-block validation) fail closed to pending; the boundary sweeps
|
|
1399
|
+
// remain the closure pass.
|
|
1400
|
+
void surfacePass.sweep(sessionId, "fold-commit").catch((error) => {
|
|
1401
|
+
decisionLog(`session=${sessionId} surface-replace failed via=fold-commit why=${error?.message ?? error}`);
|
|
1402
|
+
});
|
|
1403
|
+
return `Folded ${count} messages into block ${result.block.id}. An immediate surface ship was queued (via=fold-commit): when it lands, even frozen dispatches carry the reduced history; if the surface cannot accept it right now the block stays pending and the boundary sweeps retry.`;
|
|
1138
1404
|
},
|
|
1139
1405
|
});
|
|
1140
1406
|
} catch (error) {
|
|
@@ -1152,6 +1418,16 @@ export function applyContextTrim(ctx, config) {
|
|
|
1152
1418
|
if (applied !== null) {
|
|
1153
1419
|
viewTokens = applied.viewTokens;
|
|
1154
1420
|
mainDispatch = applied.sideDispatch !== true;
|
|
1421
|
+
if (mainDispatch) {
|
|
1422
|
+
// Advisory pressure nudge (modeNow gates inside): the model's
|
|
1423
|
+
// own fold is advisory mode's ONLY relief channel — speak at
|
|
1424
|
+
// the fold line. Fail-open, never breaks the dispatch.
|
|
1425
|
+
try {
|
|
1426
|
+
deliverAdvisoryNudge(applied.sessionId, applied.viewTokens);
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
decisionLog(`advisory-nudge-failed why=${error?.message ?? error}`);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1155
1431
|
}
|
|
1156
1432
|
}
|
|
1157
1433
|
} catch (error) {
|
|
@@ -1239,5 +1515,5 @@ export function applyContextTrim(ctx, config) {
|
|
|
1239
1515
|
warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
|
|
1240
1516
|
}
|
|
1241
1517
|
|
|
1242
|
-
return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
|
|
1518
|
+
return { store, metrics, captures, inFlight, windowCache, advisoryNudgeBySession, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
|
|
1243
1519
|
}
|