@falling-ts/dsh-force-compact 0.2.3 → 0.2.5
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.cn.md +161 -114
- package/README.md +149 -98
- package/index.js +41 -34
- package/package.json +1 -1
- package/src/core/ui-signal.js +1 -1
- package/src/engine/builtin.js +6 -0
- package/src/engine/summarizer.js +51 -0
- package/src/hooks/guard.js +41 -16
- package/src/hooks/wire-rewrite.js +105 -97
package/src/hooks/guard.js
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* before a model request is made**:
|
|
8
8
|
*
|
|
9
9
|
* - **`agent/request`** (a Waterfall around the frozen call configuration) —
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* a deliberate **pass-through** (2026-08 semantics revision): the returned
|
|
11
|
+
* `LlmCallConfig` rides UNCHANGED. `disableThinking` now scopes strictly to
|
|
12
|
+
* this plugin's own compaction summarization call (enforced inside
|
|
13
|
+
* `engine/builtin.js` → `engine/summarizer.js`); all other model requests
|
|
14
|
+
* retain the machine's own reasoning-effort configuration.
|
|
14
15
|
* - **`agent/pre-step`** (a Waterfall before each model step) — reads the
|
|
15
16
|
* session's **projected context tokens** through the official
|
|
16
17
|
* `contextPressure` projection (`projectedTokens` — the exact figure the
|
|
@@ -617,15 +618,29 @@ async function __forceCompactIfNeededBody(ctx, agent, signal, mode) {
|
|
|
617
618
|
)
|
|
618
619
|
}
|
|
619
620
|
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
// the
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
621
|
+
// SHORT-CIRCUIT: when the whole current surface window does not exceed the
|
|
622
|
+
// retention budget, there is no head to compact (the tail-retaining selector
|
|
623
|
+
// would walk the entire window and return null). The threshold was tripped
|
|
624
|
+
// by the provider-usage baseline rather than by real surface growth, so an
|
|
625
|
+
// attempted compaction is a guaranteed no-op — skip it and let the request
|
|
626
|
+
// proceed without pretending to compact.
|
|
627
|
+
if (windowSumObserved > 0 && Number.isFinite(windowSumObserved) && windowSumObserved <= settings.retainLatestTokens) {
|
|
628
|
+
ctx.logger.debug(
|
|
629
|
+
`[force-compact] ${session.id}: threshold ${settings.autoThresholdTokens} tripped on a surface window (~${Math.round(windowSumObserved)} tokens) that does not exceed retainLatestTokens (~${settings.retainLatestTokens}) — nothing above the retention floor to compact; letting the request proceed`,
|
|
630
|
+
)
|
|
631
|
+
return false
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// At or above the threshold: attempt a retained-tail compaction (the request
|
|
635
|
+
// itself always proceeds; the plugin never rejects the model call).
|
|
636
|
+
ctx.logger.debug(
|
|
637
|
+
`[force-compact] ${session.id}: context ~${total} tokens >= threshold ${settings.autoThresholdTokens} — attempting a retained-tail compaction (success/failure is reported by the backend below; the request itself proceeds regardless)`,
|
|
627
638
|
)
|
|
628
|
-
|
|
639
|
+
// NOTE: threshold path has NO originating slash-command, so the 5th argument
|
|
640
|
+
// (sourceCommandId) must be omitted — passing something else here (e.g. the
|
|
641
|
+
// `measurement` snapshot, as an earlier revision did) would leak a non-string
|
|
642
|
+
// into the compaction/* events' sourceCommandId field.
|
|
643
|
+
const committed = await compactRetainingLatest(ctx, agent, signal, mode)
|
|
629
644
|
if (!committed) {
|
|
630
645
|
// BLANK OUTCOME — nothing shrank, so the NEXT step re-attempts at the same
|
|
631
646
|
// total. That is intentional ("先压缩再说"): a blank result never wedges the
|
|
@@ -640,11 +655,21 @@ async function __forceCompactIfNeededBody(ctx, agent, signal, mode) {
|
|
|
640
655
|
}
|
|
641
656
|
|
|
642
657
|
/**
|
|
643
|
-
* Whether a model request
|
|
658
|
+
* Whether a model request SHOULD be sent with thinking/reasoning disabled.
|
|
659
|
+
*
|
|
660
|
+
* LEGACY PREDICATE (2026-08 semantics revision): the active `agent/request`
|
|
661
|
+
* hot path no longer calls this helper. `disableThinking` now scopes STRICTLY
|
|
662
|
+
* to THIS PLUGIN'S OWN compaction summarization call — enforced inside
|
|
663
|
+
* `src/engine/builtin.js` (which sources `extra.reasoningEffort` from
|
|
664
|
+
* `settings.disableThinking` and passes it to `src/engine/summarizer.js`,
|
|
665
|
+
* whose `options.reasoningEffort` stamps the `ctx.llm.stream` request). All
|
|
666
|
+
* OTHER model requests (business conversation, sub-agents, tool-driven,
|
|
667
|
+
* other plugins) ride the machine's config UNCHANGED.
|
|
644
668
|
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
669
|
+
* Kept exported so a FUTURE caller who genuinely wants the blanket
|
|
670
|
+
* "off-everywhere" semantics (or the legacy dual-layer insurance described
|
|
671
|
+
* in the stale docs) can consume the same setting through the same
|
|
672
|
+
* containment envelope without duplicating the read-settings logic.
|
|
648
673
|
*
|
|
649
674
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
650
675
|
* @returns {Promise<boolean>}
|
|
@@ -1,117 +1,127 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-force-compact's
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* dsh-force-compact's Live-UI watermark side-channel on the `llm/stream`
|
|
3
|
+
* waterfall seam — KICKS OFF a fresh random "working" one-liner on every LLM
|
|
4
|
+
* call START so the Live UI (browser) repaints the `TurnStatus` node with a
|
|
5
|
+
* new `liveUi.working` pair. Purely a PRESENTATION-LAYER concern (a settings-
|
|
6
|
+
* write on the `liveUi` field, mirrored live to the browser via the existing
|
|
7
|
+
* settings-sync channel). Performs NO wire modification whatsoever.
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* OpenAI-compatible endpoint (:8080, `Qwen3.8-27B-NVFP4-MTP-LOW.gguf`), the
|
|
14
|
-
* top-level `thinking` key is NOT in the llama.cpp request schema — it is
|
|
15
|
-
* forwarded opaquely into `llama_params` and IGNORED
|
|
16
|
-
* (verified against `D:\AI\llama.cpp\tools\server`: `thinking` appears
|
|
17
|
-
* nowhere in `server-schema.cpp`, and the OAI parsing path never reads a
|
|
18
|
-
* top-level `thinking` key). Net effect of the request-seam path ALONE:
|
|
19
|
-
* `disableThinking:true` SILENTLY FAILS to turn off thinking on llama.cpp —
|
|
20
|
-
* the model thinks anyway, with no error surfacing.
|
|
9
|
+
* Why NOT at the `llm/stream` seam for wire-fields (historical note, 2026-08)
|
|
10
|
+
* ----------------------------------------------------------------------------
|
|
11
|
+
* An EARLIER draft of this module attempted to APPEND the llama.cpp-native
|
|
12
|
+
* wire field `reasoning_effort: "none"` to outgoing LLM calls at this same
|
|
13
|
+
* `llm/stream` seam. That approach is PROVABLY INEFFECTIVE HERE for two
|
|
14
|
+
* independent structural reasons (both verified empirically against the
|
|
15
|
+
* harness dispatch code and Cordis waterfall semantics):
|
|
21
16
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* (
|
|
29
|
-
*
|
|
17
|
+
* 1. WATERFALL IS A LINEAR CHAIN THAT DISCARDS INTERMEDIATE RETURNS.
|
|
18
|
+
* `ctx.waterfall(...)` (vendor/cordis/src/events.ts:234-243) walks its
|
|
19
|
+
* listener array sequentially, always passing the SAME frozen seed
|
|
20
|
+
* `args` to each successive layer, and returns the OUTERMOST layer's
|
|
21
|
+
* value as the final stream. Intermediate layers' return values are
|
|
22
|
+
* DROPPED — they influence nothing downstream. Our plugin registers
|
|
23
|
+
* LAST (lazy-install, default `push` order), so our return reaches
|
|
24
|
+
* nobody; the innermost thunk receives the ORIGINAL seed reference
|
|
25
|
+
* regardless of what we return.
|
|
30
26
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
27
|
+
* 2. IN-PLACE ASSIGNMENT TO THE FROZEN SEED CRASHES. The seed is a deep-
|
|
28
|
+
* frozen (non-extensible) `GenerateOptions`; `seed.reasoning_effort =
|
|
29
|
+
* 'none'` throws `Cannot add property …, object is not extensible` at
|
|
30
|
+
* the instant a real LLM call fires, propagating OUT OF the listener
|
|
31
|
+
* into the host process and taking the entire `dsh web` instance down.
|
|
32
|
+
*
|
|
33
|
+
* Net effect: any injection-at-the-waterfall design either CRASHES (wall 1)
|
|
34
|
+
* or SILENTLY NO-OPS (wall 2). Both were observed in live testing on a
|
|
35
|
+
* running 3180 dev instance; that is the documented reason the module was
|
|
36
|
+
* reduced to a pure passthrough.
|
|
37
|
+
*
|
|
38
|
+
* Where the wire-append ACTUALLY lives now (since 2026-08)
|
|
39
|
+
* ----------------------------------------------------------------------
|
|
40
|
+
* The correct single-line fix landed in `src/engine/summarizer.js` IMMEDIATELY
|
|
41
|
+
* BEFORE the `llm.stream(options)` call (search for the comment block titled
|
|
42
|
+
* "LLAMA.CPP COMPATIBILITY WIRE FIELD"): when `extra.reasoningEffort === 'off'`
|
|
43
|
+
* (which `engine/builtin.js` stamps whenever `settings.disableThinking` is
|
|
44
|
+
* true), the summarizer sets `options.reasoning_effort = 'none'` ALONGSIDE the
|
|
45
|
+
* existing camelCase `options.reasoningEffort = 'off'` field (which the
|
|
46
|
+
* DeepSeek adapter serializes to `thinking:{type:'disabled'}`). Emitting BOTH
|
|
47
|
+
* fields covers BOTH endpoints simultaneously:
|
|
40
48
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* clone and returning it (or re-dispatching `this.stream(clone)`) never
|
|
51
|
-
* reaches the serializer, and re-dispatch additionally fails because the
|
|
52
|
-
* listener's `this` is not reliably the LlmRuntime here (`Cannot read
|
|
53
|
-
* properties of undefined (reading 'stream')`).
|
|
49
|
+
* • Real DeepSeek endpoint: reads `reasoningEffort` → emits
|
|
50
|
+
* `thinking: { type: 'disabled' }`; ignores the unknown snake_case
|
|
51
|
+
* `reasoning_effort` top-level key (silent no-op, no 400).
|
|
52
|
+
* • llama.cpp / OpenAI-compatible endpoint: reads the top-level
|
|
53
|
+
* `reasoning_effort: "none"` → parses natively into
|
|
54
|
+
* `inputs.enable_thinking = false` UNCONDITIONALLY
|
|
55
|
+
* (`D:\AI\llama.cpp\tools\server\server-common.cpp:1295-1304`); the
|
|
56
|
+
* adapter's `thinking` field (still present in the body) is tolerated-
|
|
57
|
+
* but-ignored there.
|
|
54
58
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* it forwards `next()`'s result (the real stream) untouched and performs NO
|
|
61
|
-
* mutation — incapable of breaking any business-path model call.
|
|
59
|
+
* Because `builtin.js` gates `extra.reasoningEffort` on
|
|
60
|
+
* `settings.disableThinking`, the wire-field rides the EXACT same scoping
|
|
61
|
+
* rule as the primary one: only emitted on COMPRACTION calls where the user
|
|
62
|
+
* has turned thinking OFF. Business-conversation requests and every other LLM
|
|
63
|
+
* call never reach `summarize()` at all, so they are UNAFFECTED.
|
|
62
64
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
65
|
+
* What THIS FILE STILL DOES (Live-UI watermark)
|
|
66
|
+
* ---------------------------------------------
|
|
67
|
+
* Independent of the wire question above, the listener KICKS OFF a fire-
|
|
68
|
+
* and-forget `publishRandomWorking(ctx)` call BEFORE the synchronization
|
|
69
|
+
* point so the Live UI paints a fresh random "working" one-liner on every LLM
|
|
70
|
+
* call start. This is purely a presentation-layer concern (a settings-write
|
|
71
|
+
* on the `liveUi` field, mirrored live to the browser so it can repaint the
|
|
72
|
+
* `TurnStatus` node) and MUST NOT BLOCK the return path. `publishRandomWorking`
|
|
73
|
+
* swallows ALL of its own rejections internally, so the fire-and-forget form
|
|
74
|
+
* leaks no unhandled rejection and never disturbs the stream.
|
|
75
|
+
*
|
|
76
|
+
* Contract guarantees
|
|
77
|
+
* --------------------
|
|
78
|
+
* • ALWAYS calls `next()` (skipping it would stall the waterfall chain).
|
|
79
|
+
* • NEVER mutates the (deep-frozen) seed, so it cannot raise
|
|
80
|
+
* `object is not extensible`.
|
|
81
|
+
* • NEVER reshapes / spreads the (stream) return value, so it cannot break
|
|
82
|
+
* the consumer's `for await`.
|
|
83
|
+
* • Is DECLARED NON-ASYNC: an `async` listener would wrap `next()`'s stream
|
|
84
|
+
* return in a Promise, and the waterfall dispatcher's downstream
|
|
85
|
+
* `yield* <promise>` would throw
|
|
86
|
+
* `yield* (intermediate value)… is not async iterable` on every call.
|
|
71
87
|
*
|
|
72
88
|
* @module @falling-ts/dsh-force-compact/wire-rewrite
|
|
73
89
|
*/
|
|
74
90
|
|
|
75
91
|
/**
|
|
76
|
-
* Register the `llm/stream` Waterfall listener. As of the
|
|
77
|
-
*
|
|
78
|
-
* forwards `next()`'s result (the async-iterable chunk stream)
|
|
79
|
-
* performs NO mutation.
|
|
80
|
-
*
|
|
81
|
-
*
|
|
92
|
+
* Register the `llm/stream` Waterfall listener. As of the historical-note
|
|
93
|
+
* section above, the listener is a DELIBERATE pure passthrough w.r.t. WIRE
|
|
94
|
+
* SEMANTICS: it forwards `next()`'s result (the async-iterable chunk stream)
|
|
95
|
+
* untouched and performs NO mutation. Its sole remaining duty is the Live-UI
|
|
96
|
+
* watermark side-channel (see the module header). See
|
|
97
|
+
* `src/engine/summarizer.js` for where the actual `reasoning_effort` wire
|
|
98
|
+
* field is now injected (at the options-construction site, NOT at this
|
|
99
|
+
* waterfall seam).
|
|
82
100
|
*
|
|
83
101
|
* Idempotent within one plugin lifetime (a process-local latch prevents
|
|
84
102
|
* double-registration across multiple `apply` invocations in tests or HMR).
|
|
85
103
|
*
|
|
86
104
|
* Contract guarantees:
|
|
87
|
-
* Contract guarantees:
|
|
88
105
|
* • ALWAYS calls `next()` (skipping it would stall the waterfall chain).
|
|
89
106
|
* • NEVER mutates the (deep-frozen) seed, so it cannot raise
|
|
90
107
|
* `object is not extensible`.
|
|
91
|
-
* • NEVER reshapes/spreads the (stream) return value, so it cannot break
|
|
92
|
-
* consumer's `for await`.
|
|
93
|
-
* • Emits at most ONE debug line (the first-of-lifetime ui-signal marker;
|
|
94
|
-
* silent thereafter).
|
|
108
|
+
* • NEVER reshapes / spreads the (stream) return value, so it cannot break
|
|
109
|
+
* the consumer's `for await`.
|
|
95
110
|
* • FIRES THE LIVE UI STATUS SIDE-CHANNEL (`core/ui-signal.js`) on every
|
|
96
111
|
* invocation — the "each LLM call start = fresh random working pair"
|
|
97
|
-
* watermark. Publication is KICKED OFF FIRE-AND-FORGET (a plain
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* untouched per the
|
|
104
|
-
* corrupt the stream.
|
|
105
|
-
* • The listener is DECLARED NON-ASYNC: an `async` listener would wrap
|
|
106
|
-
* `next()`'s stream return in a Promise, and the waterfall dispatcher's
|
|
107
|
-
* downstream `yield* <promise>` would throw
|
|
108
|
-
* `yield* (intermediate value)… is not async iterable` on every call.
|
|
112
|
+
* watermark. Publication is KICKED OFF FIRE-AND-FORGET (a plain non-
|
|
113
|
+
* awaited `publishRandomWorking(ctx)` call placed BEFORE the synchronous
|
|
114
|
+
* `return next()`) so the listener itself STAYS SYNC and can directly hand
|
|
115
|
+
* back the real stream; `publishRandomWorking` swallows all of its own
|
|
116
|
+
* rejections internally, so the fire-and-forget form leaks no unhandled
|
|
117
|
+
* rejection. It never touches `payload` (the deep-frozen seed — left
|
|
118
|
+
* untouched per the historical-note section above), so it can never stall
|
|
119
|
+
* or corrupt the stream.
|
|
109
120
|
*
|
|
110
121
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
111
122
|
* @returns {boolean} whether this call actually performed the (once-only)
|
|
112
|
-
* registration. `false` indicates it was a no-op re-entry (already
|
|
113
|
-
*
|
|
114
|
-
* will retry).
|
|
123
|
+
* registration. `false` indicates it was a no-op re-entry (already installed)
|
|
124
|
+
* or a registration failure (not installed; a later re-entry will retry).
|
|
115
125
|
*/
|
|
116
126
|
|
|
117
127
|
import { publishRandomWorking } from '../core/ui-signal.js'
|
|
@@ -131,10 +141,10 @@ export function registerLlmStreamHook(ctx) {
|
|
|
131
141
|
// internally (guaranteed side-effect-free w.r.t. the waterfall), so
|
|
132
142
|
// kicking it off without awaiting cannot leak an unhandled rejection.
|
|
133
143
|
// `payload` is the deep-frozen GenerateOptions seed — NEVER mutated (see
|
|
134
|
-
// the
|
|
135
|
-
// none of that (pure settings-write on the `liveUi` field,
|
|
136
|
-
// to the browser so it can repaint the `TurnStatus` node).
|
|
137
|
-
// documents the deliberate non-use.
|
|
144
|
+
// the historical-note section in the module header); the publication
|
|
145
|
+
// touches none of that (pure settings-write on the `liveUi` field,
|
|
146
|
+
// mirrored live to the browser so it can repaint the `TurnStatus` node).
|
|
147
|
+
// `void payload` documents the deliberate non-use.
|
|
138
148
|
ctx.on('llm/stream', (payload, next) => {
|
|
139
149
|
void payload
|
|
140
150
|
publishRandomWorking(ctx) // fire-and-forget: sync kick-off, async settle
|
|
@@ -142,10 +152,8 @@ export function registerLlmStreamHook(ctx) {
|
|
|
142
152
|
})
|
|
143
153
|
installed = true
|
|
144
154
|
return true
|
|
145
|
-
} catch {
|
|
146
|
-
|
|
147
|
-
// fatal to NOTHING — the plugin simply never installs this hook. We DO
|
|
148
|
-
// NOT mark `installed`, so a later re-entry retries.
|
|
155
|
+
} catch (err) {
|
|
156
|
+
ctx.logger?.warn('[force-compact] wire-rewrite install failed:', err?.message ?? err)
|
|
149
157
|
return false
|
|
150
158
|
}
|
|
151
159
|
}
|