@falling-ts/dsh-force-compact 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The turn-end forced compaction.
3
+ *
4
+ * Observes `agent/status`; when an agent transitions to `idle` (no driver
5
+ * remains scheduled or active — i.e. all turns done, including sub-agents) and
6
+ * `turnEndForceCompactionEnabled` is on, compacts the session's useful history
7
+ * through the compaction service's **idle manual entry** (`compactNow`). This
8
+ * fires at the "all done, before the next human turn" boundary the user
9
+ * described: the agent is not busy (all turns and sub-agent work have
10
+ * quiesced) and the next human message has not yet arrived.
11
+ *
12
+ * `compactNow` is the idle manual-compaction entry (owner `null`): it requires an
13
+ * idle agent and uses the engine's own range selection. `compactRegion` is NOT
14
+ * usable here — its owner is `current-turn`, which requires an open turn, so at
15
+ * idle it throws "no open turn". A fresh `AbortController` mints a signal per
16
+ * idle transition (an `agent/status` listener carries no turn signal of its
17
+ * own).
18
+ *
19
+ * @module @falling-ts/dsh-force-compact/turn-end
20
+ */
21
+
22
+ import { readSettings, DEFAULTS } from '../core/settings.js'
23
+ import { resolveCompaction } from '../engine/backend.js'
24
+ import { publishCompressing, publishDone } from '../core/ui-signal.js'
25
+ import { guardFn, renderCrash, captureThrowSite, appendCrashLine as appendDiag } from '../core/crashnet.js'
26
+
27
+ /**
28
+ * Handle one `agent/status` emission: when the agent transitions to `idle` and
29
+ * `turnEndForceCompactionEnabled` is on, compact the session's useful history
30
+ * through `compactNow` (the idle manual-compaction entry). Never throws out of
31
+ * the listener (a failing compaction is logged and swallowed).
32
+ * @param {import('@deepseek-ai/cordis').Context} ctx
33
+ * @param {{agent: import('@deepseek-ai/dsh-agent').Agent, status: string}} payload
34
+ * @param {string|undefined} mode the `compactionMode` setting (passed by the caller); undefined re-reads live.
35
+ * @returns {Promise<void>}
36
+ */
37
+ // SAFETY ENVELOPE: this handler fires on EVERY `agent/status` transition.
38
+ // `compactNow` is a heavyweight LLM round-trip and the idle tick recurs ~every
39
+ // 5s, so ANY uncaught throw here would repeat on every tick — the concrete
40
+ // "stutters every request / stuck" symptom. The ENTIRE body is therefore
41
+ // contained: any anomaly (malformed payload, a rejecting `readSettings`, a
42
+ // missing `agent.session`, a failing backend call) logs and returns; it NEVER
43
+ // throws into the `agent/status` dispatch. Additionally, a UNIVERSAL-CRASH-NET
44
+ // diagnostic (thrownAt site, deepest plugin frame, nearest non-plugin frame,
45
+ // full stack) is appended to the durable crash log on every degradation.
46
+ async function __handleAgentStatusEnveloped(ctx, payload, mode) {
47
+ try {
48
+ await __handleAgentStatusBody(ctx, payload, mode)
49
+ } catch (error) {
50
+ const message = error instanceof Error ? (error.stack || error.message) : String(error)
51
+ ctx.logger.warn(`[force-compact] handleAgentStatus degraded (swallowed) — ${message}`)
52
+ try {
53
+ const lines = renderCrash('idle.handleAgentStatus', error, captureThrowSite())
54
+ for (const line of lines) appendDiag(line)
55
+ } catch (_netFailure) { /* swallow */ }
56
+ }
57
+ }
58
+
59
+ export const handleAgentStatus = guardFn('idle.handleAgentStatus', __handleAgentStatusEnveloped)
60
+
61
+ /** Body of {@link handleAgentStatus}; wrapped by its safe envelope. */
62
+ async function __handleAgentStatusBody(ctx, payload, mode) {
63
+ if (payload === null || typeof payload !== 'object') return
64
+ const agent = payload.agent
65
+ const status = payload.status
66
+ if (status !== 'idle') return
67
+ // A usable session is required to address the compaction; without one there is
68
+ // nothing to do (and no id to log) — degrade quietly rather than deref crash.
69
+ const session = (agent && typeof agent === 'object') ? agent.session : undefined
70
+ const sid = (session && typeof session.id === 'string') ? session.id : '?'
71
+ const settings = (await readSettings(ctx)) ?? DEFAULTS
72
+ if (settings.turnEndForceCompactionEnabled !== true) {
73
+ // Visible so a tester who flipped the setting OFF can confirm the guard is
74
+ // what suppressed the idle compaction (not a missing listener).
75
+ ctx.logger.debug(`[force-compact] ${sid}: turn-end compaction disabled by settings — idle transition ignored`)
76
+ return
77
+ }
78
+
79
+ if (session === undefined || session === null || typeof session.id !== 'string') {
80
+ ctx.logger.debug(`[force-compact] ${sid}: idle transition observed but agent.session is unusable — skipping turn-end compaction`)
81
+ return
82
+ }
83
+ // Locate a usable compaction backend: the OFFICIAL `compaction` service
84
+ // (preferred) OR this plugin's OWN builtin engine (fallback when the service
85
+ // is realm-isolated away — see `engine/backend.js`). At idle the agent is
86
+ // still live in the registry, so its realm-scoped context still resolves the
87
+ // official instance when one exists; otherwise the builtin engine takes over.
88
+ const backend = await resolveCompaction(ctx, agent, mode)
89
+ if (backend === undefined || typeof backend.compactNow !== 'function') {
90
+ const effMode = (mode !== undefined ? mode : settings.compactionMode)
91
+ ctx.logger.warn(
92
+ `[force-compact] ${session.id}: NO compaction backend available at idle (mode=${effMode}). ` +
93
+ `Either the official \`compaction\` service is realm-isolated (standard preset) AND ` +
94
+ `\`builtinEnabled=false\`, OR the builtin engine is missing a prerequisite ` +
95
+ `(needs \`agent.session\` and the \`llm\` service). Enable \`builtinEnabled=true\` in the ` +
96
+ `\`falling-ts-force-compact\` namespace to restore the fallback.`
97
+ )
98
+ return
99
+ }
100
+
101
+ // compactNow is the idle manual-compaction entry (owner null). It requires an
102
+ // idle agent and uses the engine's own range selection. A fresh
103
+ // AbortController mints a signal (a status listener carries no turn signal of
104
+ // its own).
105
+ const controller = new AbortController()
106
+ try {
107
+ // LIVE UI SIGNAL — PIN RED "compressing" BEFORE requesting the model /
108
+ // committing anything. Both publishers are guaranteed side-effect-free
109
+ // (they swallow their own failures internally), so a messenger problem
110
+ // can never perturb the compaction transaction itself.
111
+ await publishCompressing(ctx)
112
+ // P1 — idle is an AUTO entry: pass `opts: { retainTokens }` to preserve
113
+ // the legacy retain-the-latest-N-tokens selection. Without this, `compactNow`
114
+ // defaults `retainTokens` to 0 (manual full-head behavior) which would
115
+ // change idle-path semantics. The 3rd arg (sourceCommandId) stays
116
+ // undefined — idle has no originating command id.
117
+ const result = await backend.compactNow(agent, controller.signal, undefined, { retainTokens: settings.retainLatestTokens })
118
+ if (result === undefined || result === null) {
119
+ ctx.logger.debug(`[force-compact] ${session.id}: idle compaction via ${backend?.kind} committed nothing`)
120
+ return
121
+ }
122
+ // COMMITTED — range shadowed + summary added. Pin GREEN "done" NOW; the
123
+ // next model request's `llm/stream` watermark redraws a fresh random
124
+ // working pair within seconds (typically < 3 s — the very next step's
125
+ // LLM boundary), which is the natural visual rhythm: no dedicated timer
126
+ // needed.
127
+ await publishDone(ctx)
128
+ ctx.logger.info(
129
+ `[force-compact] ${session.id}: idle compaction (${backend?.kind}) shadowed ${result.shadowedSeqs?.length ?? '?'} nodes `
130
+ + `(~${result.shadowedTokenCount ?? '?'} tokens)`,
131
+ )
132
+ } catch (error) {
133
+ const message = error instanceof Error ? error.message : String(error)
134
+ ctx.logger.warn(`[force-compact] ${session.id}: idle compaction via ${backend?.kind} FAILED — ${message}`)
135
+ }
136
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * dsh-force-compact's wire-layer second safety net for disabling thinking —
3
+ * appends the OPENAI-COMPATIBLE wire field `reasoning_effort: "none"` to
4
+ * EVERY outgoing LLM call whenever the `disableThinking` setting is on.
5
+ *
6
+ * The dual-layer insurance rationale
7
+ * ---------------------------------
8
+ * The `disableThinking` setting is ALSO honored upstream at the request-seam:
9
+ * the `agent/request` waterfall sets `LlmCallConfig.reasoningEffort = 'off'`,
10
+ * which the DeepSeek adapter serializes to the wire field
11
+ * `thinking: { type: 'disabled' }`. That is the RIGHT field for the real
12
+ * DeepSeek API. But when the SAME wire shape lands on a llama.cpp
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.
21
+ *
22
+ * The llama.cpp-native (and OpenAI-generic) spelling for "disable reasoning"
23
+ * is a TOP-LEVEL wire field `reasoning_effort: "none"`
24
+ * (`server-common.cpp:1295-1304`), which the OAI parser special-cases into
25
+ * `inputs.enable_thinking = false` UNCONDITIONALLY — independent of jinja
26
+ * template capability. A weaker alternative,
27
+ * `chat_template_kwargs: { enable_thinking: false }`
28
+ * (`server-common.cpp:1286-1291`), depends on the template honoring the
29
+ * `enable_thinking` placeholder; `reasoning_effort:"none"` is more robust.
30
+ *
31
+ * Current state: NEUTERED to a pure passthrough
32
+ * ---------------------------------------------
33
+ * The original intent was "Layer 2" — append a SECOND wire field
34
+ * `reasoning_effort: "none"` at the `llm/stream` seam on top of the
35
+ * adapter's `thinking:{type:'disabled'}` (Layer 1) so that a llama.cpp /
36
+ * OpenAI-compatible endpoint (which ignores the top-level `thinking` key)
37
+ * would ALSO receive a field it honors. Live testing PROVED that this cannot
38
+ * be achieved at the `llm/stream` waterfall seam without modifying vendor
39
+ * code, because of two hard walls discovered empirically:
40
+ *
41
+ * 1. FROZEN SEED. The waterfall passes EVERY listener the SAME deep-frozen
42
+ * `GenerateOptions` seed (`payload`; `ctx.waterfall` re-invokes inner
43
+ * layers with the identical args — see `vendor/cordis/src/events.ts#
44
+ * waterfall`). In-place assignment `options.reasoning_effort = …` throws
45
+ * `Cannot add property …, object is not extensible` at the exact moment
46
+ * a real LLM call fires, which PROPAGATES OUT OF the listener and TAKES
47
+ * DOWN THE ENTIRE `dsh web` PROCESS.
48
+ * 2. GENERATOR BINDS THE SEED. The base `adapterStream` is an AsyncGenerator
49
+ * that BOUND the seed object at construction; building a fresh mutable
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')`).
54
+ *
55
+ * Therefore injecting a NEW top-level wire field at this seam requires either
56
+ * a vendor change (out of scope) or a dedicated `registerAdapter` for
57
+ * llama.cpp (would collide with / be shadowed by the existing DeepSeek-route
58
+ * adapter). Rather than ship a hook that either silently no-ops or CRASHES the
59
+ * host, this file is deliberately reduced to a GUARANTEED pure passthrough:
60
+ * it forwards `next()`'s result (the real stream) untouched and performs NO
61
+ * mutation — incapable of breaking any business-path model call.
62
+ *
63
+ * The AUTHORITATIVE disable-thinking mechanism remains Layer 1 (unchanged):
64
+ * the `agent/request` waterfall sets `reasoningEffort:'off'`, which the
65
+ * DeepSeek adapter serializes to the native `thinking:{type:'disabled'}` —
66
+ * honored by the real DeepSeek API. On a llama.cpp / OpenAI-compatible
67
+ * endpoint that top-level field is tolerated-but-ignored, so thinking stays
68
+ * on there — IDENTICAL to having no plugin at all (a documented limitation,
69
+ * never a crash). Restoring the wire-append requires a future dedicated
70
+ * llama.cpp adapter or a vendor-supported options-extension seam.
71
+ *
72
+ * @module @falling-ts/dsh-force-compact/wire-rewrite
73
+ */
74
+
75
+ /**
76
+ * Register the `llm/stream` Waterfall listener. As of the neutering described
77
+ * in the module header, the listener is a DELIBERATE pure passthrough: it
78
+ * forwards `next()`'s result (the async-iterable chunk stream) untouched and
79
+ * performs NO mutation. See the module header for why the intended
80
+ * `reasoning_effort:"none"` wire-append is NOT possible at this seam without a
81
+ * vendor change or a dedicated llama.cpp adapter.
82
+ *
83
+ * Idempotent within one plugin lifetime (a process-local latch prevents
84
+ * double-registration across multiple `apply` invocations in tests or HMR).
85
+ *
86
+ * Contract guarantees:
87
+ * Contract guarantees:
88
+ * • ALWAYS calls `next()` (skipping it would stall the waterfall chain).
89
+ * • NEVER mutates the (deep-frozen) seed, so it cannot raise
90
+ * `object is not extensible`.
91
+ * • NEVER reshapes/spreads the (stream) return value, so it cannot break the
92
+ * consumer's `for await`.
93
+ * • Emits at most ONE debug line (the first-of-lifetime ui-signal marker;
94
+ * silent thereafter).
95
+ * • FIRES THE LIVE UI STATUS SIDE-CHANNEL (`core/ui-signal.js`) on every
96
+ * invocation — the "each LLM call start = fresh random working pair"
97
+ * watermark. Publication is KICKED OFF FIRE-AND-FORGET (a plain
98
+ * non-awaited `publishRandomWorking(ctx)` call placed BEFORE the
99
+ * synchronous `return next()`) so the listener itself STAYS SYNC and can
100
+ * directly hand back the real stream; `publishRandomWorking` swallows all
101
+ * of its own rejections internally, so the fire-and-forget form leaks no
102
+ * unhandled rejection. It never touches `payload` (the deep-frozen seed —
103
+ * untouched per the two-hard-walls note above), so it can never stall or
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.
109
+ *
110
+ * @param {import('@deepseek-ai/cordis').Context} ctx
111
+ * @returns {boolean} whether this call actually performed the (once-only)
112
+ * registration. `false` indicates it was a no-op re-entry (already
113
+ * installed) or a registration failure (not installed; a later re-entry
114
+ * will retry).
115
+ */
116
+
117
+ import { publishRandomWorking } from '../core/ui-signal.js'
118
+ let installed = false
119
+ export function registerLlmStreamHook(ctx) {
120
+ if (installed) return false
121
+ try {
122
+ // CONTRACT: this listener MUST stay SYNCHRONOUS. The `llm/stream`
123
+ // waterfall expects each layer's RETURN VALUE to BE the (async-iterable)
124
+ // chunk stream itself, so that downstream layers / the dispatcher can
125
+ // immediately `yield*` (or `for await`) it. Making the listener `async`
126
+ // wraps that return in a PROMISE, and the downstream `yield* <promise>`
127
+ // explodes with `yield* (intermediate value)… is not async iterable` —
128
+ // exactly the crash observed on every built-in compaction attempt. The
129
+ // side-channel publication therefore runs FIRE-AND-FORGET (never blocking
130
+ // the return): `publishRandomWorking` swallows ALL of its own rejections
131
+ // internally (guaranteed side-effect-free w.r.t. the waterfall), so
132
+ // kicking it off without awaiting cannot leak an unhandled rejection.
133
+ // `payload` is the deep-frozen GenerateOptions seed — NEVER mutated (see
134
+ // the two-hard-walls note in the module header); the publication touches
135
+ // none of that (pure settings-write on the `liveUi` field, mirrored live
136
+ // to the browser so it can repaint the `TurnStatus` node). `void payload`
137
+ // documents the deliberate non-use.
138
+ ctx.on('llm/stream', (payload, next) => {
139
+ void payload
140
+ publishRandomWorking(ctx) // fire-and-forget: sync kick-off, async settle
141
+ return next() // synchronous return of the REAL stream
142
+ })
143
+ installed = true
144
+ return true
145
+ } catch {
146
+ // Registration failure (rare: e.g. a context without a usable `on`) is
147
+ // fatal to NOTHING — the plugin simply never installs this hook. We DO
148
+ // NOT mark `installed`, so a later re-entry retries.
149
+ return false
150
+ }
151
+ }