dsh-switchman 1.11.4 → 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 +7 -5
- package/README.es.md +7 -5
- package/README.fr.md +7 -5
- package/README.it.md +7 -5
- package/README.ja.md +7 -5
- package/README.ko.md +7 -5
- package/README.md +10 -8
- package/README.pt.md +7 -5
- package/README.ru.md +7 -5
- package/README.zh-TW.md +7 -5
- package/README.zh.md +7 -5
- package/client.js +205 -63
- package/cordis.patch.yml +17 -1
- package/host/config.js +24 -8
- package/host/context-trim/index.js +300 -27
- package/host/context-trim/summarizer.js +46 -35
- 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/ptc-guard.js +6 -0
- package/host/routes.js +13 -14
- package/package.json +1 -1
package/cordis.patch.yml
CHANGED
|
@@ -136,6 +136,9 @@
|
|
|
136
136
|
name: '@deepseek-ai/dsh-tool-schedule'
|
|
137
137
|
- id: skill-filesystem
|
|
138
138
|
name: '@deepseek-ai/dsh-skill-filesystem'
|
|
139
|
+
config:
|
|
140
|
+
customSkillDirs:
|
|
141
|
+
- !!js process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:path').dirname(process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('@deepseek-ai/dsh-agent-preset/package.json')), 'skills')
|
|
139
142
|
- id: tool-skill
|
|
140
143
|
name: '@deepseek-ai/dsh-tool-skill'
|
|
141
144
|
- id: command-goal
|
|
@@ -196,6 +199,8 @@
|
|
|
196
199
|
config:
|
|
197
200
|
- id: tool-subagent-control
|
|
198
201
|
name: '@deepseek-ai/dsh-tool-subagent-control'
|
|
202
|
+
config:
|
|
203
|
+
interrupt: false
|
|
199
204
|
- id: tool-subagent
|
|
200
205
|
name: '@deepseek-ai/dsh-tool-subagent'
|
|
201
206
|
config:
|
|
@@ -227,6 +232,8 @@
|
|
|
227
232
|
searchTimeoutMs: 60000
|
|
228
233
|
- id: present
|
|
229
234
|
name: '@deepseek-ai/dsh-tool-present'
|
|
235
|
+
- id: tool-office
|
|
236
|
+
name: '@deepseek-ai/dsh-tool-office'
|
|
230
237
|
- id: tool-plugin-manager
|
|
231
238
|
name: '@deepseek-ai/dsh-plugin-manager/tools'
|
|
232
239
|
disabled: !!js "!ctx.get('profileContext')"
|
|
@@ -335,6 +342,8 @@
|
|
|
335
342
|
config:
|
|
336
343
|
- id: tool-subagent-control
|
|
337
344
|
name: '@deepseek-ai/dsh-tool-subagent-control'
|
|
345
|
+
config:
|
|
346
|
+
interrupt: false
|
|
338
347
|
- id: tool-subagent
|
|
339
348
|
name: '@deepseek-ai/dsh-tool-subagent'
|
|
340
349
|
config:
|
|
@@ -370,6 +379,8 @@
|
|
|
370
379
|
name: '@deepseek-ai/dsh-tool-executor-ptc'
|
|
371
380
|
- id: present
|
|
372
381
|
name: '@deepseek-ai/dsh-tool-present'
|
|
382
|
+
- id: tool-office
|
|
383
|
+
name: '@deepseek-ai/dsh-tool-office'
|
|
373
384
|
- id: tool-plugin-manager
|
|
374
385
|
name: '@deepseek-ai/dsh-plugin-manager/tools'
|
|
375
386
|
disabled: true
|
|
@@ -488,7 +499,7 @@
|
|
|
488
499
|
name: '@deepseek-ai/dsh-persona'
|
|
489
500
|
config:
|
|
490
501
|
prefix: >-
|
|
491
|
-
You are a coding agent powered by the {{model}} model.
|
|
502
|
+
You are a coding agent powered by the {{model}} model. You help the user customize DeepSeek Harness itself.
|
|
492
503
|
suffix: *switchman-team-suffix
|
|
493
504
|
- id: agent-instructions
|
|
494
505
|
name: '@deepseek-ai/dsh-agent-instructions'
|
|
@@ -572,6 +583,8 @@
|
|
|
572
583
|
config:
|
|
573
584
|
- id: tool-subagent-control
|
|
574
585
|
name: '@deepseek-ai/dsh-tool-subagent-control'
|
|
586
|
+
config:
|
|
587
|
+
interrupt: false
|
|
575
588
|
- id: tool-subagent
|
|
576
589
|
name: '@deepseek-ai/dsh-tool-subagent'
|
|
577
590
|
config:
|
|
@@ -653,10 +666,13 @@
|
|
|
653
666
|
trimIncrementTokens: 25000
|
|
654
667
|
trimPressurePercent: 65
|
|
655
668
|
trimEmergencyPercent: 95
|
|
669
|
+
trimAdvisoryPercent: 65
|
|
670
|
+
trimAdvisoryCriticalPercent: 95
|
|
656
671
|
trimWindowTokens: 0
|
|
657
672
|
trimProtectedTail: 10
|
|
658
673
|
trimMaxFoldsPerHour: 30
|
|
659
674
|
trimLadderEnabled: true
|
|
675
|
+
trimProtectTaskIntent: true
|
|
660
676
|
trimLadderT2Tokens: 8000
|
|
661
677
|
trimLadderT2MinBlocks: 3
|
|
662
678
|
trimLadderT3Tokens: 6000
|
package/host/config.js
CHANGED
|
@@ -67,10 +67,9 @@ export const IntentCandidateSchema = z.object({
|
|
|
67
67
|
* ModelRouteSchema this accepts the "unset" `{provider:"", model:""}`
|
|
68
68
|
* pair the settings page round-trips — ModelRouteSchema's `.min(1)`
|
|
69
69
|
* would reject that exact declared default on every settings write.
|
|
70
|
-
* Consumers validate non-empty before trusting the route. effort
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* intent-layer routes leave it unset. */
|
|
70
|
+
* Consumers validate non-empty before trusting the route. `effort` is an
|
|
71
|
+
* optional override for summary routes and intent fallback; the classifier
|
|
72
|
+
* route remains effort-free in its route cleaner. */
|
|
74
73
|
const IntentRouteSchema = z.object({
|
|
75
74
|
provider: z.string(),
|
|
76
75
|
model: z.string(),
|
|
@@ -240,8 +239,9 @@ export const Config = z.object({
|
|
|
240
239
|
* the 0.8×-window auto-compaction (see effectiveContextWindow in
|
|
241
240
|
* host/intent.js). */
|
|
242
241
|
intentContextWindow: z.number().min(1024).default(200000).volatile(),
|
|
243
|
-
/** Optional
|
|
244
|
-
*
|
|
242
|
+
/** Optional fallback route after classification fails when no prior route
|
|
243
|
+
* is available; empty = the first candidate. An optional effort pin
|
|
244
|
+
* overrides the fallback model's default. */
|
|
245
245
|
intentFallbackRoute: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
|
|
246
246
|
|
|
247
247
|
// --- Context trim: fully-automatic conversation compression ------------
|
|
@@ -267,6 +267,14 @@ export const Config = z.object({
|
|
|
267
267
|
/** Emergency tier (tier c): forced MECHANICAL fold (no side-model call)
|
|
268
268
|
* at this percent of the window; cooldown-gated. */
|
|
269
269
|
trimEmergencyPercent: z.number().min(1).max(100).default(95).volatile(),
|
|
270
|
+
/** Advisory mode speak line: the model is nudged to fold when pressure
|
|
271
|
+
* reaches this percent of the window (default sits below the protocol's
|
|
272
|
+
* own 80% fold line, nudging while folding headroom is still comfortable). */
|
|
273
|
+
trimAdvisoryPercent: z.number().min(1).max(100).default(65).volatile(),
|
|
274
|
+
/** Advisory mode escalation line: at this percent the nudge escalates to a
|
|
275
|
+
* fold-down-this-turn order; must stay above trimAdvisoryPercent (an
|
|
276
|
+
* inverted or out-of-range pair falls back to 65/95). */
|
|
277
|
+
trimAdvisoryCriticalPercent: z.number().min(1).max(100).default(95).volatile(),
|
|
270
278
|
/** [2026-11-07]-[growth/increment constants are absolute tokens
|
|
271
279
|
* calibrated for the default 150k effective window]-[scaling
|
|
272
280
|
* trimWindowTokens down without scaling trimGrowthTokens /
|
|
@@ -292,18 +300,26 @@ export const Config = z.object({
|
|
|
292
300
|
* budget and the ladder starved. */
|
|
293
301
|
trimMaxFoldsPerHour: z.number().min(1).max(60).default(30).volatile(),
|
|
294
302
|
/** Route for the side-model summarizer (L3). Empty = inherit the
|
|
295
|
-
* session's current model
|
|
303
|
+
* session's current provider/model; an optional effort pin overrides
|
|
304
|
+
* the model's automatic recommended effort. */
|
|
296
305
|
trimSideModel: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
|
|
297
306
|
/** Route for the fold-fallback summarizer. When the primary side route
|
|
298
307
|
* (trimSideModel or the inherited session model) fails, raw folds retry
|
|
299
308
|
* on this route before degrading to a mechanical fold; ladder folds
|
|
300
|
-
* retry but never go mechanical.
|
|
309
|
+
* retry but never go mechanical. An optional effort pin overrides the
|
|
310
|
+
* model's automatic recommendation. Pick a route with headroom on cooldown,
|
|
301
311
|
* risk-control, and concurrency limits. Empty = no fallback leg. */
|
|
302
312
|
trimFoldFallbackModel: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
|
|
303
313
|
/** T1/T2/T3 ladder folding (docs/ladder-fold.md): master switch for the
|
|
304
314
|
* T2/T3 distillation tiers. T1 raw-range folds are governed by
|
|
305
315
|
* trimMode; the ladder only adds the summary-maintenance tiers. */
|
|
306
316
|
trimLadderEnabled: z.boolean().default(true).volatile(),
|
|
317
|
+
/** Protect the task-intent segment (the first user message's whole
|
|
318
|
+
* segment) from raw folds. true (default) keeps the anti-drift guard;
|
|
319
|
+
* false lets the intent segment enter computeFoldableRange's candidate
|
|
320
|
+
* span like any other raw content. The system-head clamp is unaffected
|
|
321
|
+
* either way. */
|
|
322
|
+
trimProtectTaskIntent: z.boolean().default(true).volatile(),
|
|
307
323
|
/** Ladder T2 gate: minimum active-T1 summary token mass before a T2
|
|
308
324
|
* distillation fires (the need signal — bili #379: block count alone
|
|
309
325
|
* is not a need signal). */
|
|
@@ -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)
|
|
@@ -695,10 +907,9 @@ export function applyContextTrim(ctx, config) {
|
|
|
695
907
|
} else if (view.applied > 0) {
|
|
696
908
|
epochResetDoneBySession.delete(sessionId);
|
|
697
909
|
}
|
|
698
|
-
//
|
|
699
|
-
// provider/model
|
|
700
|
-
//
|
|
701
|
-
// verbatim instead of re-deriving (and losing) it.
|
|
910
|
+
// Record the active request's route and resolved effort for diagnostics only.
|
|
911
|
+
// The side summarizer follows provider/model only and resolves effort from its
|
|
912
|
+
// model metadata rather than inheriting this dispatch's effort.
|
|
702
913
|
const model = {
|
|
703
914
|
provider: options.provider ?? null,
|
|
704
915
|
model: options.model ?? null,
|
|
@@ -718,16 +929,14 @@ export function applyContextTrim(ctx, config) {
|
|
|
718
929
|
}
|
|
719
930
|
|
|
720
931
|
/** Resolve the side-model route: explicit trimSideModel route, else the
|
|
721
|
-
* session's own model
|
|
932
|
+
* session's own provider/model. Effort is resolved from that model's
|
|
933
|
+
* supported thinking metadata, not inherited from the active session call. */
|
|
722
934
|
function sideRouteFor(sessionId) {
|
|
723
935
|
const explicit = readRoute(config, "trimSideModel");
|
|
724
936
|
if (explicit !== null) return explicit;
|
|
725
937
|
const model = metrics.of(sessionId)?.lastModel ?? null;
|
|
726
938
|
if (model !== null && typeof model.provider === "string" && model.provider !== "" && typeof model.model === "string" && model.model !== "") {
|
|
727
|
-
|
|
728
|
-
return typeof model.effort === "string" && model.effort !== ""
|
|
729
|
-
? { provider: model.provider, model: model.model, effort: model.effort }
|
|
730
|
-
: { provider: model.provider, model: model.model };
|
|
939
|
+
return { provider: model.provider, model: model.model };
|
|
731
940
|
}
|
|
732
941
|
return null;
|
|
733
942
|
}
|
|
@@ -747,7 +956,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
747
956
|
|
|
748
957
|
/** Run one fold cycle for a session (called at the idle boundary ONLY).
|
|
749
958
|
* Returns a short outcome string for logging. */
|
|
750
|
-
async function runFoldCycle(sessionId, decision, cycleStartedAt) {
|
|
959
|
+
async function runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken) {
|
|
751
960
|
const capture = captures.get(sessionId);
|
|
752
961
|
if (capture === undefined) return "no-capture";
|
|
753
962
|
const { protectedTail } = thresholdsNow();
|
|
@@ -773,6 +982,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
773
982
|
// Ladder folds never degrade to mechanical (docs/ladder-fold.md):
|
|
774
983
|
// after both routes fail the cycle stays fail-open with backoff.
|
|
775
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
|
|
776
986
|
const result = store.commitFold(
|
|
777
987
|
sessionId,
|
|
778
988
|
{ consumes: decision.consumes.slice(), summary, kind: "summary", tier: decision.tier === "ladder-3" ? 3 : 2, approxTokens },
|
|
@@ -831,6 +1041,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
831
1041
|
if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
|
|
832
1042
|
}
|
|
833
1043
|
const positions = refPositions(capture.messages);
|
|
1044
|
+
if (inFlight.get(sessionId) !== cycleToken) return "superseded"; // watchdog released us; a retry owns the session
|
|
834
1045
|
const result = store.commitFold(
|
|
835
1046
|
sessionId,
|
|
836
1047
|
{ refRange: { firstId: refs[0], lastId: refs[refs.length - 1] }, count, approxTokens, summary, kind, tier: decision.tier },
|
|
@@ -877,15 +1088,28 @@ export function applyContextTrim(ctx, config) {
|
|
|
877
1088
|
decisionLog(`session=${sessionId} via=${via} action=none reason=injection-unavailable view=${Math.round(capture.viewTokens)}`);
|
|
878
1089
|
return;
|
|
879
1090
|
}
|
|
880
|
-
inFlight.add(sessionId);
|
|
881
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?.();
|
|
882
1106
|
// Hoisted for the catch below: a dispose-cut throw may land before the
|
|
883
1107
|
// trigger ever ran, and the catch must not touch a dead binding.
|
|
884
1108
|
let decision = null;
|
|
885
1109
|
try {
|
|
886
|
-
const { protectedTail, ...thresholds } = thresholdsNow();
|
|
1110
|
+
const { protectedTail, protectTaskIntent, ...thresholds } = thresholdsNow();
|
|
887
1111
|
const ledger = store.ledgerOf(sessionId);
|
|
888
|
-
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
|
|
1112
|
+
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
|
|
889
1113
|
const windowTokens = await windowTokensFor(capture.model);
|
|
890
1114
|
// Pressure tiers run on the REAL prompt size when the tally is known:
|
|
891
1115
|
// max(view, wire). The view estimate misses the system/context
|
|
@@ -908,12 +1132,15 @@ export function applyContextTrim(ctx, config) {
|
|
|
908
1132
|
});
|
|
909
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"}`);
|
|
910
1134
|
if (decision.action === "none") return;
|
|
911
|
-
const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt);
|
|
1135
|
+
const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken);
|
|
912
1136
|
// Failure ladder: commit resets it; a summarize/commit failure
|
|
913
1137
|
// climbs it (the no-backoff loop this closes — those outcomes
|
|
914
1138
|
// advance no cadence gate on their own). A ladder run whose
|
|
915
1139
|
// consumed blocks vanished between trigger and commit is the same
|
|
916
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.
|
|
917
1144
|
if (outcome.startsWith("folded:")) clearFoldFailure(sessionId);
|
|
918
1145
|
else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:") || outcome === "ladder-consumed-missing") recordFoldFailure(sessionId);
|
|
919
1146
|
// Outcome lands in the decision log too: warn() has no reachable
|
|
@@ -930,7 +1157,10 @@ export function applyContextTrim(ctx, config) {
|
|
|
930
1157
|
warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
|
|
931
1158
|
}
|
|
932
1159
|
} finally {
|
|
933
|
-
|
|
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);
|
|
934
1164
|
}
|
|
935
1165
|
}
|
|
936
1166
|
|
|
@@ -1040,19 +1270,27 @@ export function applyContextTrim(ctx, config) {
|
|
|
1040
1270
|
name: "ctx_trim_status",
|
|
1041
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.",
|
|
1042
1272
|
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
1043
|
-
|
|
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
|
+
},
|
|
1044
1282
|
async execute() {
|
|
1045
1283
|
const sessionId = sessionIdOf({});
|
|
1046
1284
|
if (sessionId === null) return "No session context available.";
|
|
1047
1285
|
if (!enabledNow()) return "Context trimming is disabled for this deployment.";
|
|
1048
1286
|
const ledger = store.ledgerOf(sessionId);
|
|
1049
1287
|
const capture = captures.get(sessionId);
|
|
1050
|
-
const { protectedTail } = thresholdsNow();
|
|
1288
|
+
const { protectedTail, protectTaskIntent } = thresholdsNow();
|
|
1051
1289
|
const lines = [`ctx_trim_status — mode: ${modeNow()} (enabled: ${enabledNow()})`];
|
|
1052
1290
|
lines.push(...renderStatusLedger(ledger));
|
|
1053
1291
|
// The raw range a fold would cover next, with its true size.
|
|
1054
1292
|
if (capture !== undefined) {
|
|
1055
|
-
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
|
|
1293
|
+
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
|
|
1056
1294
|
if (foldable !== null) {
|
|
1057
1295
|
const firstRef = refOf(capture.messages[foldable.startIdx], foldable.startIdx);
|
|
1058
1296
|
const lastRef = refOf(capture.messages[foldable.endIdx], foldable.endIdx);
|
|
@@ -1090,6 +1328,11 @@ export function applyContextTrim(ctx, config) {
|
|
|
1090
1328
|
required: ["firstId", "lastId", "count", "summary"],
|
|
1091
1329
|
additionalProperties: false,
|
|
1092
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
|
+
},
|
|
1093
1336
|
async execute(args) {
|
|
1094
1337
|
const sessionId = sessionIdOf({});
|
|
1095
1338
|
if (sessionId === null) return "No session context available.";
|
|
@@ -1113,6 +1356,15 @@ export function applyContextTrim(ctx, config) {
|
|
|
1113
1356
|
}
|
|
1114
1357
|
if (firstIdx < 0) { firstIdx = firstList[0]; lastIdx = lastList[lastList.length - 1]; }
|
|
1115
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
|
+
}
|
|
1116
1368
|
const { protectedTail } = thresholdsNow();
|
|
1117
1369
|
if (lastIdx >= capture.messages.length - Math.max(0, protectedTail)) return `Range reaches into the protected tail (last ${protectedTail} messages are never folded).`;
|
|
1118
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).";
|
|
@@ -1137,7 +1389,18 @@ export function applyContextTrim(ctx, config) {
|
|
|
1137
1389
|
const lowLedger = store.ledgerOf(sessionId);
|
|
1138
1390
|
const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
|
|
1139
1391
|
store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
|
|
1140
|
-
|
|
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.`;
|
|
1141
1404
|
},
|
|
1142
1405
|
});
|
|
1143
1406
|
} catch (error) {
|
|
@@ -1155,6 +1418,16 @@ export function applyContextTrim(ctx, config) {
|
|
|
1155
1418
|
if (applied !== null) {
|
|
1156
1419
|
viewTokens = applied.viewTokens;
|
|
1157
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
|
+
}
|
|
1158
1431
|
}
|
|
1159
1432
|
}
|
|
1160
1433
|
} catch (error) {
|
|
@@ -1242,5 +1515,5 @@ export function applyContextTrim(ctx, config) {
|
|
|
1242
1515
|
warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
|
|
1243
1516
|
}
|
|
1244
1517
|
|
|
1245
|
-
return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
|
|
1518
|
+
return { store, metrics, captures, inFlight, windowCache, advisoryNudgeBySession, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
|
|
1246
1519
|
}
|