@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.
- package/LICENSE +21 -0
- package/README.cn.md +170 -0
- package/README.md +479 -0
- package/cordis.patch.yml +13 -0
- package/index.js +550 -0
- package/package.json +54 -0
- package/src/core/crashnet.js +215 -0
- package/src/core/log.js +346 -0
- package/src/core/pairing.js +188 -0
- package/src/core/policy.js +35 -0
- package/src/core/projected.js +139 -0
- package/src/core/settings.js +475 -0
- package/src/core/ui-signal.js +271 -0
- package/src/engine/backend.js +143 -0
- package/src/engine/builtin.js +1336 -0
- package/src/engine/checkpoint.js +206 -0
- package/src/engine/region.js +579 -0
- package/src/engine/summarizer.js +818 -0
- package/src/hooks/command.js +164 -0
- package/src/hooks/guard.js +706 -0
- package/src/hooks/idle.js +136 -0
- package/src/hooks/wire-rewrite.js +151 -0
- package/web/client.js +807 -0
- package/web/swish.css +188 -0
|
@@ -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
|
+
}
|