@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,818 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-force-compact's own one-shot summarization, modeled on the official
|
|
3
|
+
* `compaction-basic` summarizer. Builds a compaction directive as the final user
|
|
4
|
+
* message after the replayed region (so the provider's KV cache is reused),
|
|
5
|
+
* streams it through `ctx.llm`, and returns the condensed checkpoint.
|
|
6
|
+
*
|
|
7
|
+
* This is a pre-commit preview + shrink gate: the durable surface mutation is
|
|
8
|
+
* delegated to the `compaction` service's `compactRegion` (read live via
|
|
9
|
+
* `ctx.get('compaction')`), which is the authoritative summarizer. See
|
|
10
|
+
* `engine/checkpoint.js` for the orchestration.
|
|
11
|
+
*
|
|
12
|
+
* @module @falling-ts/dsh-force-compact/summarizer
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { guardFn } from '../core/crashnet.js'
|
|
16
|
+
|
|
17
|
+
/** Tag opening the structured summary block inside a landed checkpoint node.
|
|
18
|
+
* (The matching close tag is the symmetric `</compacted-summary>`; it is kept
|
|
19
|
+
* as a literal where emitted rather than a second constant, since the open tag
|
|
20
|
+
* is the sole anchor referenced elsewhere — by the prior-checkpoint rule in
|
|
21
|
+
* `COMPACTION_INSTRUCTION`.) */
|
|
22
|
+
export const SUMMARY_OPEN_TAG = '<compacted-summary>'
|
|
23
|
+
|
|
24
|
+
/** Closing counterpart of {@link SUMMARY_OPEN_TAG}; wraps the structured
|
|
25
|
+
* summary body inside a landed checkpoint (aligned with the official
|
|
26
|
+
* `compaction-basic` `frameSummary`, which emits both tags around the body). */
|
|
27
|
+
export const SUMMARY_CLOSE_TAG = '</compacted-summary>'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The compaction directive, delivered as the FINAL user message after the
|
|
31
|
+
* replayed conversation rather than as a distinct summarizer system prompt.
|
|
32
|
+
* Keeping the conversation's own system prompt, tools, and message prefix in
|
|
33
|
+
* front of it makes the auxiliary call a genuine prefix of the last routed
|
|
34
|
+
* request, so the provider's KV cache is reused instead of invalidated.
|
|
35
|
+
*/
|
|
36
|
+
export const COMPACTION_INSTRUCTION = [
|
|
37
|
+
'You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context.',
|
|
38
|
+
'',
|
|
39
|
+
'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
|
|
40
|
+
'',
|
|
41
|
+
'## Primary Request and Intent',
|
|
42
|
+
"- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
|
|
43
|
+
'',
|
|
44
|
+
'## Key Technical Concepts',
|
|
45
|
+
'- [technologies, frameworks, patterns, and conventions in play]',
|
|
46
|
+
'',
|
|
47
|
+
'## Files and Code',
|
|
48
|
+
'- [exact path: why it matters, key changes or snippets]',
|
|
49
|
+
'',
|
|
50
|
+
'## Errors and Fixes',
|
|
51
|
+
'- [error: how it was resolved, plus any related user feedback]',
|
|
52
|
+
'',
|
|
53
|
+
'## Pending Jobs',
|
|
54
|
+
'- [explicitly requested work not yet completed]',
|
|
55
|
+
'',
|
|
56
|
+
'## Current Work',
|
|
57
|
+
'- [precisely what was in progress at this checkpoint]',
|
|
58
|
+
'',
|
|
59
|
+
'## Next Step',
|
|
60
|
+
'- [the single next action, directly in line with the most recent request, or "(none)"]',
|
|
61
|
+
'',
|
|
62
|
+
'## Critical Context',
|
|
63
|
+
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
|
|
64
|
+
'',
|
|
65
|
+
'Rules:',
|
|
66
|
+
'- Write concise engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.',
|
|
67
|
+
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
|
|
68
|
+
'- Do NOT mention this summarization request or that the context was compacted.',
|
|
69
|
+
'- Output only the checkpoint text: do not call any tool or take any other action.',
|
|
70
|
+
`- If the conversation already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
|
|
71
|
+
].join('\n')
|
|
72
|
+
|
|
73
|
+
/** Framing that makes the replacement user message established context. */
|
|
74
|
+
export const CHECKPOINT_PREAMBLE =
|
|
75
|
+
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Frame summarized content blocks for landing as a `user/message` — aligned with
|
|
79
|
+
* the official `compaction-basic` `frameSummary` (a BLOCK-LEVEL wrap, NOT a
|
|
80
|
+
* text splice): the first block carries the preamble and the OPEN
|
|
81
|
+
* `<compacted-summary>` tag, the summary's own text blocks pass through VERBATIM
|
|
82
|
+
* (preserving their exact content), and a trailing block carries the CLOSE tag.
|
|
83
|
+
* Wrapping as discrete blocks (rather than joining into one string) keeps each
|
|
84
|
+
* fragment individually addressable and matches the durable event shape exactly.
|
|
85
|
+
* So a later compression recognizing the `<compacted-summary>` anchor applies the
|
|
86
|
+
* instruction's "merge, don't copy forward" rule correctly.
|
|
87
|
+
* @param {Array<{type:'text', text:string}>} textBlocks the condensed checkpoint blocks.
|
|
88
|
+
* @returns {Array<{type:'text', text:string}>} the framed node blocks.
|
|
89
|
+
*/
|
|
90
|
+
export function frameSummary(textBlocks) {
|
|
91
|
+
const blocks = Array.isArray(textBlocks) ? textBlocks.filter(b => b && b.type === 'text' && typeof b.text === 'string') : []
|
|
92
|
+
return [
|
|
93
|
+
{ type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
|
|
94
|
+
...blocks,
|
|
95
|
+
{ type: 'text', text: SUMMARY_CLOSE_TAG },
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The replayed conversation surface the summarizer condenses. Reproducing the
|
|
101
|
+
* last routed request's system prompt, tools, and leading messages verbatim
|
|
102
|
+
* lets the auxiliary call reuse the provider's warm prefix cache; the trailing
|
|
103
|
+
* compaction instruction is then the only novel input.
|
|
104
|
+
*/
|
|
105
|
+
/**
|
|
106
|
+
* @typedef {{ system?: string, tools?: ReadonlyArray<object>, messages: Array<object> }} SummarizationInput
|
|
107
|
+
* Replayed prefix for the summarization call. `system` and `tools` come from
|
|
108
|
+
* the session's latest request header (prefix-cache alignment); `messages` is
|
|
109
|
+
* the shadowed region in surface order.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @typedef {{ provider: string, model: string }|undefined} Target
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Run the cache-reusing `ctx.llm.stream()` summarization call.
|
|
118
|
+
*
|
|
119
|
+
* Aligned with the official `compaction-basic` summarizer (single-source-of-
|
|
120
|
+
* truth pattern):
|
|
121
|
+
* 1. target resolution order: **configured** (`config.summarizationProvider` /
|
|
122
|
+
* `config.summarizationModel`, optional) → **latest routed header**
|
|
123
|
+
* (`agent.session.requestHeader().config`) → **agent.options**
|
|
124
|
+
* (`provider` / `model`). The first candidate with BOTH fields wins.
|
|
125
|
+
* 2. The replayed prefix (system + tools from the request header, plus the
|
|
126
|
+
* shadowed-region messages) is passed VERBATIM so the auxiliary call is a
|
|
127
|
+
* genuine prefix of the last routed request — the provider's warm KV
|
|
128
|
+
* cache is reused instead of invalidated. The compaction instruction is
|
|
129
|
+
* then the only novel input.
|
|
130
|
+
* 3. `purpose: 'compaction'` tags the call for adapter-side routing policy.
|
|
131
|
+
* 4. All chunk kinds are accumulated; a terminal `finish` of `error` /
|
|
132
|
+
* `aborted` / `max-tokens` throws a typed error (caller closes the
|
|
133
|
+
* transaction via `closeWithError`); image output is refused (image
|
|
134
|
+
* content can never safely become a checkpoint).
|
|
135
|
+
* 5. Usage is surfaced when the provider reports it.
|
|
136
|
+
*
|
|
137
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
138
|
+
* @param {Readonly<object>} config backend config (may carry `maxSummaryTokens`
|
|
139
|
+
* and the optional `summarizationProvider` / `summarizationModel` override pair)
|
|
140
|
+
* @param {import('@deepseek-ai/dsh-agent').Agent} agent provides the session
|
|
141
|
+
* (routed-header lookup) and fallback target.
|
|
142
|
+
* @param {SummarizationInput} input replayed prefix + region messages.
|
|
143
|
+
* @param {AbortSignal} [signal]
|
|
144
|
+
* @param {{ reasoningEffort?: 'off' | 'low' | 'high' | 'max', maxTokens?: number }} [extra]
|
|
145
|
+
* optional generation overrides: `reasoningEffort` maps to the LLM
|
|
146
|
+
* adapter's thinking toggle; `maxTokens` OVERRIDES the `config.maxSummaryTokens`
|
|
147
|
+
* base value when present (callers route through the `settings.maxSummaryTokens`
|
|
148
|
+
* runtime knob without changing the static default).
|
|
149
|
+
* @returns {Promise<object>} NEVER rejects. A discriminated result object the
|
|
150
|
+
* caller branches on by `status`:
|
|
151
|
+
* • `{ status: 'ok', summary, provider, model, maxTokens?, usage? }` —
|
|
152
|
+
* `summary` is the condensed text-only checkpoint blocks.
|
|
153
|
+
* • `{ status: 'no-target' }` / `{ status: 'no-llm' }` — the call was never
|
|
154
|
+
* made; caller silently skips (nothing to cool down).
|
|
155
|
+
* • `{ status: '<failure>', reason: string }` — the call was made but no
|
|
156
|
+
* usable summary resulted. Failure labels: `not-iterable`, `no-finish`,
|
|
157
|
+
* `provider-error`, `aborted`, `truncated-empty`, `image-content`,
|
|
158
|
+
* `empty-text`. Caller arms the per-session cooldown and closes the
|
|
159
|
+
* transaction with `error`.
|
|
160
|
+
* No throw path exists: a malformed chunk/finish/object degrades to a
|
|
161
|
+
* labeled failure, so a bad provider response can never surface a TypeError
|
|
162
|
+
* nor trap the idle path in an uncaught-exception retry loop.
|
|
163
|
+
*/
|
|
164
|
+
// Internal body of `summarize` — routed through the crash-net wrapper. The
|
|
165
|
+
// documented contract is "NEVER THROWS", but a genuinely novel throw shape
|
|
166
|
+
// (e.g. a `JSON.stringify` on a cycle, an exotic iterator) escapes into the
|
|
167
|
+
// crash net, leaving a durable trace and propagating the original value
|
|
168
|
+
// unchanged (existing callers keep their semantics).
|
|
169
|
+
async function __summarizeBody(ctx, config, agent, input, signal, extra) {
|
|
170
|
+
// NEVER THROWS. Always resolves to a structured result the caller branches on
|
|
171
|
+
// by `status`:
|
|
172
|
+
// { status: 'ok', summary[], provider, model, maxTokens?, usage? }
|
|
173
|
+
// { status: 'no-target' } — no resolvable provider/model (call never made)
|
|
174
|
+
// { status: 'no-llm' } — ctx has no `llm.stream` service (call never made)
|
|
175
|
+
// { status: '<failure>', reason: string } — the call was MADE but produced no usable
|
|
176
|
+
// summary. Failures:
|
|
177
|
+
// 'not-iterable' (returned stream not an async iterable),
|
|
178
|
+
// 'no-finish' (stream consumed but delivered no terminal finish chunk),
|
|
179
|
+
// 'provider-error' (terminal finish kind:'error'),
|
|
180
|
+
// 'aborted' (terminal finish kind:'aborted'),
|
|
181
|
+
// 'truncated-empty' (kind:'max-tokens' with no text),
|
|
182
|
+
// 'image-content' (image blocks present — unsafe as a checkpoint),
|
|
183
|
+
// 'empty-text' (terminated successfully but emitted no text).
|
|
184
|
+
// The caller (builtin.js runTransaction) maps 'ok' → commit; 'no-target'/
|
|
185
|
+
// 'no-llm' → silent skip (nothing to cool down); any other status → arm the
|
|
186
|
+
// per-session failure cooldown + close the transaction with `error`. No throw
|
|
187
|
+
// path exists, so a malformed chunk/finish/object can never propagate a
|
|
188
|
+
// TypeError and the idle path can never loop on an uncaught exception.
|
|
189
|
+
const target = resolveTarget(config, agent)
|
|
190
|
+
if (target === undefined) return { status: 'no-target' }
|
|
191
|
+
|
|
192
|
+
const llm = ctx.get('llm')
|
|
193
|
+
if (llm === undefined || typeof llm.stream !== 'function') return { status: 'no-llm' }
|
|
194
|
+
|
|
195
|
+
// Backwards-compatible call signature: a bare `messages` array (the old
|
|
196
|
+
// 4-arg form) is treated as an input with no system/tools prefix. New
|
|
197
|
+
// callers pass `{ messages, system?, tools? }`.
|
|
198
|
+
const normalized = Array.isArray(input) ? { messages: input } : input
|
|
199
|
+
const regionMessages = normalized.messages || []
|
|
200
|
+
|
|
201
|
+
const request = [
|
|
202
|
+
...regionMessages,
|
|
203
|
+
{ role: 'user', content: [{ type: 'text', text: COMPACTION_INSTRUCTION }], source: { kind: 'plugin', plugin: 'force-compact' } },
|
|
204
|
+
]
|
|
205
|
+
const options = {
|
|
206
|
+
provider: target.provider,
|
|
207
|
+
model: target.model,
|
|
208
|
+
messages: request,
|
|
209
|
+
maxTokens: config.maxSummaryTokens,
|
|
210
|
+
}
|
|
211
|
+
// Prefix-cache alignment: feed the conversation's own system prompt and tool
|
|
212
|
+
// schemas into the auxiliary call so the provider's warm KV cache for the
|
|
213
|
+
// last routed request is REUSED rather than invalidated. Absent headers
|
|
214
|
+
// (system-less session or a tool-free request) omit the field entirely.
|
|
215
|
+
if (normalized.system !== undefined && typeof normalized.system === 'string' && normalized.system.length > 0) {
|
|
216
|
+
options.system = normalized.system
|
|
217
|
+
}
|
|
218
|
+
if (Array.isArray(normalized.tools) && normalized.tools.length > 0) {
|
|
219
|
+
options.tools = [...normalized.tools]
|
|
220
|
+
}
|
|
221
|
+
// The force-compact "disable thinking" setting maps to a per-request
|
|
222
|
+
// `reasoningEffort: 'off'`, which the adapter turns into
|
|
223
|
+
// `thinking: { type: 'disabled' }` (provider thinking off for the call).
|
|
224
|
+
if (extra !== undefined && typeof extra.reasoningEffort === 'string') {
|
|
225
|
+
options.reasoningEffort = extra.reasoningEffort
|
|
226
|
+
}
|
|
227
|
+
// Callers may override the static `config.maxSummaryTokens` via the
|
|
228
|
+
// `extra.maxTokens` knob (e.g., to honor the `settings.maxSummaryTokens`
|
|
229
|
+
// runtime setting without changing the compile-time default). Only apply
|
|
230
|
+
// when the caller supplied a positive numeric override — otherwise keep the
|
|
231
|
+
// static `config` value.
|
|
232
|
+
if (extra !== undefined && Number.isFinite(extra.maxTokens) && extra.maxTokens > 0) {
|
|
233
|
+
options.maxTokens = extra.maxTokens
|
|
234
|
+
}
|
|
235
|
+
if (signal !== undefined) options.signal = signal
|
|
236
|
+
const session = agent.session
|
|
237
|
+
if (session !== undefined && session !== null && typeof session.id === 'string') {
|
|
238
|
+
options.sessionId = session.id
|
|
239
|
+
}
|
|
240
|
+
// This one-shot call IS the compaction: tag it with the closed-union
|
|
241
|
+
// `purpose` the LLM service understands (adapters may map it to
|
|
242
|
+
// purpose-specific generation policy). The agent's free-form purpose string
|
|
243
|
+
// is NOT a valid `GenerateOptions.purpose` value.
|
|
244
|
+
options.purpose = 'compaction'
|
|
245
|
+
|
|
246
|
+
// Assemble ALL chunk kinds (text + reasoning + images). Reasoning deltas are
|
|
247
|
+
// dropped later by `extractTextOnly`; a terminal finish decides whether the
|
|
248
|
+
// call succeeded. Accumulating the full stream mirrors the official
|
|
249
|
+
// `compaction-basic` `BlockAssembler` shape — the difference is that this
|
|
250
|
+
// plugin does not depend on `@deepseek-ai/dsh-llm` symbols (it ships as
|
|
251
|
+
// plain JS outside the DSH workspace), so the assembly logic is inlined here
|
|
252
|
+
// against the documented `StreamChunk` shape.
|
|
253
|
+
// Bind the stream ONCE (rather than inline in the collectChunks call) so the
|
|
254
|
+
// missing-finish diagnostic below can describe the ACTUAL object we were given
|
|
255
|
+
// — distinguishing "not async-iterable (swapped by a waterfall listener)" from
|
|
256
|
+
// "iterated cleanly but never delivered a finish chunk".
|
|
257
|
+
const stream = llm.stream(options)
|
|
258
|
+
// `collectChunks` performs the up-front async-iterability assertion and, if the
|
|
259
|
+
// returned value is NOT a real async iterable (a `llm/stream` waterfall
|
|
260
|
+
// listener swapped it for a Promise/plain object), it RESOLVES to
|
|
261
|
+
// `{ _rejected: true, _rejectReason, ... }` rather than throwing. Any other
|
|
262
|
+
// shape is treated as a degenerate collection (zero chunks, no finish) below.
|
|
263
|
+
// We therefore wrap in try/catch as belt-and-braces: even a stray iteration
|
|
264
|
+
// error degrades to a labeled failure instead of escaping `summarize`.
|
|
265
|
+
let collected
|
|
266
|
+
try {
|
|
267
|
+
collected = await collectChunks(stream, signal)
|
|
268
|
+
} catch (err) {
|
|
269
|
+
// `for await` threw mid-iteration (generator fault, network reset, a
|
|
270
|
+
// poisoned composed stream, …). Record it and fall through to the shared
|
|
271
|
+
// failure handling — never let it escape this function.
|
|
272
|
+
collected = {
|
|
273
|
+
blocks: [], text: '', hasImage: false, finish: undefined, usage: undefined,
|
|
274
|
+
_chunkCount: 0, _rejected: true,
|
|
275
|
+
_rejectReason: (err && err.message) ? err.message : String(err),
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (!collected || typeof collected !== 'object') {
|
|
279
|
+
collected = { blocks: [], text: '', hasImage: false, finish: undefined, usage: undefined, _chunkCount: 0, _rejected: true, _rejectReason: 'collectChunks returned a non-object' }
|
|
280
|
+
}
|
|
281
|
+
if (collected._rejected) {
|
|
282
|
+
// The stream value was not a usable async iterable (see collectChunks).
|
|
283
|
+
return { status: 'not-iterable', reason: 'llm.stream() did not return an async iterable: ' + (collected._rejectReason || describeStream(stream)) }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---- Defensive read of the terminal `finish` ---------------------------
|
|
287
|
+
// We do NOT trust that `finish` is a well-formed object. Every property is
|
|
288
|
+
// read behind an explicit validity guard; any anomaly degrades to a labeled
|
|
289
|
+
// failure instead of throwing.
|
|
290
|
+
const finish = collected.finish
|
|
291
|
+
const finishIsObject = finish !== null && typeof finish === 'object'
|
|
292
|
+
const finishKind = finishIsObject ? (typeof finish.kind === 'string' ? finish.kind : undefined) : undefined
|
|
293
|
+
|
|
294
|
+
// No terminal finish chunk (or the chunk carried no recognizable `kind`):
|
|
295
|
+
// the stream stopped without telling us it completed. Report the observed
|
|
296
|
+
// facts and give up — never assume success.
|
|
297
|
+
if (finish === undefined) {
|
|
298
|
+
const n = (typeof collected._chunkCount === 'number') ? collected._chunkCount : '?'
|
|
299
|
+
// DISAMBIGUATE the two sub-classes of a missing terminator: a stream that
|
|
300
|
+
// observed a trailing `usage` chunk reached a NORMAL end (and would have
|
|
301
|
+
// been synthesized into a `stop` by `collectChunks` — so landing here
|
|
302
|
+
// MEANS it died mid-way before any usage), versus a silent/hung stream
|
|
303
|
+
// that yielded chunks but never signalled completion at all. The two point
|
|
304
|
+
// at entirely different upstream causes.
|
|
305
|
+
const explicit = collected._explicitFinish
|
|
306
|
+
const suffix = explicit === undefined
|
|
307
|
+
? ''
|
|
308
|
+
: (explicit
|
|
309
|
+
? `; an explicit finish WAS observed earlier but a later phase lost it — investigate the composed stream chain`
|
|
310
|
+
: `; no usage chunk preceded the end either — the stream simply went silent`)
|
|
311
|
+
return {
|
|
312
|
+
status: 'no-finish',
|
|
313
|
+
reason: `stream ended without a terminal finish chunk (collected ${n} chunks; stream was ${describeStream(stream)}${suffix})`,
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (!finishIsObject || finishKind === undefined) {
|
|
317
|
+
// A `finish` chunk was seen but its payload was not the expected
|
|
318
|
+
// `FinishReason` object (e.g. `chunk.reason` was undefined/null or a
|
|
319
|
+
// primitive). Treat as an incomplete termination.
|
|
320
|
+
return {
|
|
321
|
+
status: 'no-finish',
|
|
322
|
+
reason: `terminal finish chunk lacked a valid kind (finish rendered as ${renderFinish(finish)})`,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Official `FinishReason.kind` closed union (upstream types.ts):
|
|
327
|
+
// 'stop' | 'tool-calls' | 'max-tokens' | 'aborted' | 'error'.
|
|
328
|
+
if (finishKind === 'error') {
|
|
329
|
+
// TEMPORARY CRASH-HARNESS PROBE: the recurring terminal failure
|
|
330
|
+
// `provider failure: Cannot read properties of undefined (reading 'kind')`
|
|
331
|
+
// (code UNKNOWN) tells us SOMEWHERE inside the composed stream chain a
|
|
332
|
+
// harness/middleware reader dereferenced `undefined.kind`. To finally root-
|
|
333
|
+
// cause it, dump EVERYTHING observable about the failure fact on the single
|
|
334
|
+
// branch where such a crash lands. Self-limiting: writes at most one line
|
|
335
|
+
// per errored call, logging never propagates, removable once root-caused.
|
|
336
|
+
try {
|
|
337
|
+
const f = readProp(finish, 'failure')
|
|
338
|
+
const stackTop = (() => {
|
|
339
|
+
const st = f && typeof f.stack === 'string' ? f.stack : (new Error('probe-no-stack-on-failure')).stack
|
|
340
|
+
// Keep the frames INSIDE the harness/adapter (skip this probe's own
|
|
341
|
+
// frames): drop lines mentioning this file, keep the rest, max 6.
|
|
342
|
+
const frames = st.split('\n').filter(line => !line.includes('summarizer.js'))
|
|
343
|
+
return frames.slice(0, 6).map(line => line.trim()).join(' <- ')
|
|
344
|
+
})()
|
|
345
|
+
const causeChain = []
|
|
346
|
+
let cursor = f && f.cause
|
|
347
|
+
for (let depth = 0; depth < 4 && cursor !== undefined && cursor !== null; depth++) {
|
|
348
|
+
causeChain.push(typeof cursor === 'object'
|
|
349
|
+
? { ctor: (cursor.constructor && cursor.constructor.name) || '?', message: cursor.message, name: cursor.name, code: cursor.code }
|
|
350
|
+
: { primitive: cursor })
|
|
351
|
+
cursor = (typeof cursor === 'object') ? cursor.cause : undefined
|
|
352
|
+
}
|
|
353
|
+
const failJson = (() => {
|
|
354
|
+
try { return JSON.stringify({ kind: finish.kind, msg: f && f.message, code: f && f.code }).slice(0, 400) } catch { return '<unserializable>' }
|
|
355
|
+
})()
|
|
356
|
+
console.log(`[force-compact] CRASH-HARNESS: failureFact=${JSON.stringify(f && typeof f === 'object' ? { ctor: f.constructor && f.constructor.name, protoKeys: Object.getOwnPropertyNames(Object.getPrototypeOf(f)).slice(0, 12), ownKeys: Object.keys(f).slice(0, 12) } : f, (k, v) => (k === 'stack' ? '<omitted>' : v)) } `
|
|
357
|
+
+ `failJson=${failJson} causeChain=${JSON.stringify(causeChain).slice(0, 600)} `
|
|
358
|
+
+ `stackInsideHarness=[${stackTop}] optionsShape={provider:${options.provider},model:${options.model},msgs:${options.messages.length},tools:${options.tools !== undefined ? options.tools.length : 'absent'},system:${typeof options.system},purpose:${options.purpose},effort:${options.reasoningEffort}}`)
|
|
359
|
+
} catch { /* the probe itself must never mask the original outcome */ }
|
|
360
|
+
return { status: 'provider-error', reason: 'provider failure: ' + (failureText(readProp(finish, 'failure')) || 'unknown provider error') }
|
|
361
|
+
}
|
|
362
|
+
if (finishKind === 'aborted') {
|
|
363
|
+
return { status: 'aborted', reason: 'aborted during generation: ' + (failureText(readProp(finish, 'failure')) || '(caller abort)') }
|
|
364
|
+
}
|
|
365
|
+
if (finishKind === 'max-tokens') {
|
|
366
|
+
// Truncation at the token cap. Empty → useless (report); non-empty → a
|
|
367
|
+
// partial summary we ACCEPT, letting the downstream shrink gate arbitrate.
|
|
368
|
+
if (measuredLength(safeText(collected.text)) === 0) {
|
|
369
|
+
return { status: 'truncated-empty', reason: 'truncated at the token cap with no output' }
|
|
370
|
+
}
|
|
371
|
+
// fall through to extraction (partial accept)
|
|
372
|
+
}
|
|
373
|
+
// `stop` / `tool-calls` / any unrecognized kind are treated as a normal
|
|
374
|
+
// termination — fall through to extracting whatever text was produced. An
|
|
375
|
+
// unrecognized `kind` is NOT fatal: we still surface any text and let the
|
|
376
|
+
// shrink gate decide usefulness.
|
|
377
|
+
|
|
378
|
+
// ---- Text extraction --------------------------------------------------
|
|
379
|
+
// `collected.blocks` is expected to be an array; a missing/non-array value
|
|
380
|
+
// simply yields an empty extraction (handled by the empty-text branch).
|
|
381
|
+
const extracted = extractTextOnly(Array.isArray(collected.blocks) ? collected.blocks : [])
|
|
382
|
+
|
|
383
|
+
// Image output can never safely become a checkpoint user-message.
|
|
384
|
+
if (collected.hasImage === true) {
|
|
385
|
+
return { status: 'image-content', reason: 'output contained image content — refusing to land as a checkpoint' }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const usableText = extracted.some(b => b != null && typeof b === 'object' && typeof b.text === 'string' && b.text.trim().length > 0)
|
|
389
|
+
if (!usableText) {
|
|
390
|
+
return { status: 'empty-text', reason: 'produced no usable text' }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const result = {
|
|
394
|
+
status: 'ok',
|
|
395
|
+
summary: extracted,
|
|
396
|
+
provider: target.provider,
|
|
397
|
+
model: target.model,
|
|
398
|
+
maxTokens: options.maxTokens,
|
|
399
|
+
}
|
|
400
|
+
// Surface provider-reported usage when the adapter carried it; callers can
|
|
401
|
+
// record it alongside `compaction/summary` for observability.
|
|
402
|
+
const usage = collected.usage
|
|
403
|
+
if (usage !== undefined && usage !== null) result.usage = usage
|
|
404
|
+
return result
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Read a single property defensively. Never throws regardless of `obj` shape.
|
|
409
|
+
* Returns `undefined` for non-objects or missing/invalid values. Used wherever
|
|
410
|
+
* we read into a structure we do not control (chunk payloads, finish reasons).
|
|
411
|
+
* @param {*} obj
|
|
412
|
+
* @param {string} key
|
|
413
|
+
* @returns {*}
|
|
414
|
+
*/
|
|
415
|
+
function readProp(obj, key) {
|
|
416
|
+
try {
|
|
417
|
+
if (obj === null || typeof obj !== 'object') return undefined
|
|
418
|
+
return obj[key]
|
|
419
|
+
} catch {
|
|
420
|
+
return undefined
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Coerce a possibly-missing text field to a safe string for measurement. */
|
|
425
|
+
function safeText(value) {
|
|
426
|
+
return typeof value === 'string' ? value : ''
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Render a possibly-malformed `finish` value for diagnostics without throwing. */
|
|
430
|
+
function renderFinish(finish) {
|
|
431
|
+
try {
|
|
432
|
+
if (finish === undefined) return 'undefined'
|
|
433
|
+
if (finish === null) return 'null'
|
|
434
|
+
if (typeof finish !== 'object') return typeof finish
|
|
435
|
+
const k = typeof finish.kind === 'string' ? `kind='${finish.kind}'` : 'kind=<missing>'
|
|
436
|
+
return `an object (${k})`
|
|
437
|
+
} catch {
|
|
438
|
+
return '<undescribable>'
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Resolve the provider/model for the summarization call, in priority order
|
|
444
|
+
* (mirroring the official summarizer's three-tier target resolution):
|
|
445
|
+
*
|
|
446
|
+
* 1. CONFIGURED target: `config.summarizationProvider` /
|
|
447
|
+
* `config.summarizationModel` — an operator-declared override (both
|
|
448
|
+
* fields must be non-empty strings to count as a target).
|
|
449
|
+
* 2. Latest ROUTED HEADER: `agent.session.requestHeader().config` — the
|
|
450
|
+
* session's most recent routed request; the model the conversation is
|
|
451
|
+
* actually running on.
|
|
452
|
+
* 3. AGENT OPTIONS: `agent.options.provider` / `agent.options.model` —
|
|
453
|
+
* the Agent's configured fallback.
|
|
454
|
+
*
|
|
455
|
+
* @param {Readonly<object>} config
|
|
456
|
+
* @param {import('@deepseek-ai/dsh-agent').Agent} agent
|
|
457
|
+
* @returns {Target}
|
|
458
|
+
*/
|
|
459
|
+
function resolveTarget(config, agent) {
|
|
460
|
+
const cfgProvider = typeof config.summarizationProvider === 'string' ? config.summarizationProvider : ''
|
|
461
|
+
const cfgModel = typeof config.summarizationModel === 'string' ? config.summarizationModel : ''
|
|
462
|
+
if (cfgProvider.length > 0 && cfgModel.length > 0) {
|
|
463
|
+
return { provider: cfgProvider, model: cfgModel }
|
|
464
|
+
}
|
|
465
|
+
const session = agent.session
|
|
466
|
+
if (session !== undefined && typeof session.requestHeader === 'function') {
|
|
467
|
+
try {
|
|
468
|
+
const header = session.requestHeader()
|
|
469
|
+
const hconfig = header && header.config
|
|
470
|
+
if (hconfig !== undefined
|
|
471
|
+
&& typeof hconfig.provider === 'string' && hconfig.provider.length > 0
|
|
472
|
+
&& typeof hconfig.model === 'string' && hconfig.model.length > 0) {
|
|
473
|
+
return { provider: hconfig.provider, model: hconfig.model }
|
|
474
|
+
}
|
|
475
|
+
} catch {
|
|
476
|
+
// requestHeader is best-effort: a malformed header folds to undefined;
|
|
477
|
+
// fall through to the agent-options fallback below.
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const opts = agent.options || {}
|
|
481
|
+
if (typeof opts.provider === 'string' && opts.provider.length > 0
|
|
482
|
+
&& typeof opts.model === 'string' && opts.model.length > 0) {
|
|
483
|
+
return { provider: opts.provider, model: opts.model }
|
|
484
|
+
}
|
|
485
|
+
return undefined
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Public entry — wrapped by the universal crash net. */
|
|
489
|
+
export const summarize = guardFn('summarizer.summarize', __summarizeBody)
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Read the session's latest request header and project the prefix-cache
|
|
493
|
+
* alignment fields (`system` prompt + `tools` schemas) out of it. Used by
|
|
494
|
+
* callers that want to reproduce the last routed request's verbatim prefix so
|
|
495
|
+
* the auxiliary summarization call hits the provider's warm KV cache.
|
|
496
|
+
*
|
|
497
|
+
* NEVER throws: any receiver shape (including a header whose `config` is
|
|
498
|
+
* absent) degrades gracefully to `{ system: undefined, tools: undefined }`.
|
|
499
|
+
*
|
|
500
|
+
* @param {import('@deepseek-ai/dsh-agent').Session|undefined} session
|
|
501
|
+
* @returns {{ system?: string, tools?: object[] }}
|
|
502
|
+
*/
|
|
503
|
+
export function headerPrefix(session) {
|
|
504
|
+
const result = {}
|
|
505
|
+
if (session === undefined || session === null || typeof session.requestHeader !== 'function') return result
|
|
506
|
+
let header
|
|
507
|
+
try {
|
|
508
|
+
header = session.requestHeader()
|
|
509
|
+
} catch {
|
|
510
|
+
return result
|
|
511
|
+
}
|
|
512
|
+
if (header === undefined || header === null) return result
|
|
513
|
+
if (typeof header.system === 'string' && header.system.length > 0) result.system = header.system
|
|
514
|
+
if (Array.isArray(header.tools) && header.tools.length > 0) result.tools = [...header.tools]
|
|
515
|
+
return result
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Collect EVERY chunk kind from the stream into a small accumulator that
|
|
520
|
+
* mimics the official `BlockAssembler`: ordered content blocks, a boolean
|
|
521
|
+
* `hasImage` flag, the terminal `finish` fact, and the provider-reported
|
|
522
|
+
* `usage` (when the adapter carried it).
|
|
523
|
+
*
|
|
524
|
+
* Inline against the documented `StreamChunk` shape — this plugin ships as
|
|
525
|
+
* plain JS outside the DSH workspace and must not import `@deepseek-ai/dsh-llm`
|
|
526
|
+
* symbols (they are not resolvable at plugin load time).
|
|
527
|
+
*
|
|
528
|
+
* `finish` is stored AS-IS as the raw `FinishReason` object (`{ kind,
|
|
529
|
+
* failure? }`) — the same thing the official `BlockAssembler` retains — so the
|
|
530
|
+
* caller switches on `finish.kind` and reads `finish.failure` per protocol.
|
|
531
|
+
*
|
|
532
|
+
* @param {AsyncIterable<object>} stream
|
|
533
|
+
* @param {AbortSignal} [signal]
|
|
534
|
+
* @param {{recordOnEmpty?: boolean}} [opts]
|
|
535
|
+
* @returns {Promise<{ blocks: Array, text: string, hasImage: boolean, finish: object|undefined, usage: object|undefined }> }
|
|
536
|
+
*/
|
|
537
|
+
async function collectChunks(stream, signal, opts) {
|
|
538
|
+
// UP-FRONT ITERABILITY CHECK: `ctx.llm.stream()` is contractually an
|
|
539
|
+
// `AsyncIterable<StreamChunk>` (direct `for await`, no outer await /
|
|
540
|
+
// `.values()`). But `llm.stream` COMPOSES every `llm/stream` waterfall
|
|
541
|
+
// listener, and a listener that `return`s a Promise or a plain (non-
|
|
542
|
+
// async-iterable) object poisons the composition — turning the stream into
|
|
543
|
+
// exactly the unstable "sometimes throws not-async-iterable, sometimes
|
|
544
|
+
// yields nothing" we observed. Detect it HERE with a precise, actionable
|
|
545
|
+
// message naming the offending constructor, instead of letting a confusing
|
|
546
|
+
// `yield* (intermediate value)…` TypeError leak out later.
|
|
547
|
+
const asyncIterFn = (stream && typeof stream === 'object')
|
|
548
|
+
? (stream[Symbol.asyncIterator]?.bind(stream))
|
|
549
|
+
: undefined
|
|
550
|
+
if (typeof asyncIterFn !== 'function') {
|
|
551
|
+
// The returned value is NOT a usable async iterable (a `llm/stream`
|
|
552
|
+
// waterfall listener swapped the generator for a Promise/plain object, or
|
|
553
|
+
// the value was undefined/null). Rather than THROWING — which would bubble
|
|
554
|
+
// an opaque `yield*`-style TypeError far up the stack — RESOLVE to a
|
|
555
|
+
// marked failure result so the caller can report it as a labeled
|
|
556
|
+
// `not-iterable` summarization failure and recover normally. No partial
|
|
557
|
+
// result is possible (nothing could be consumed), so nothing is salvaged.
|
|
558
|
+
const detail = describeStream(stream)
|
|
559
|
+
return {
|
|
560
|
+
blocks: [], text: '', hasImage: false, finish: undefined, usage: undefined,
|
|
561
|
+
_chunkCount: 0, _rejected: true,
|
|
562
|
+
_rejectReason: `did NOT return an async iterable (got ${detail}); a llm/stream waterfall listener likely replaced the generator with another object`,
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const blocks = []
|
|
566
|
+
let text = ''
|
|
567
|
+
let hasImage = false
|
|
568
|
+
let finish
|
|
569
|
+
let usage
|
|
570
|
+
|
|
571
|
+
// Accumulators keyed by BLOCK INDEX, mirroring the official `BlockAssembler`:
|
|
572
|
+
// a `block-start` opens a slot; `*-delta`s fill it; `block-end` closes it.
|
|
573
|
+
// When no `block-start` precedes a delta (some adapters omit it), we lazily
|
|
574
|
+
// open the slot on first delta using the chunk's own index. This keeps streamed
|
|
575
|
+
// output and the final assembled blocks in agreement regardless of whether the
|
|
576
|
+
// adapter emits explicit delimiters.
|
|
577
|
+
const partials = new Map()
|
|
578
|
+
const order = []
|
|
579
|
+
|
|
580
|
+
const ensure = (index, blockType) => {
|
|
581
|
+
let p = partials.get(index)
|
|
582
|
+
if (!p) {
|
|
583
|
+
p = { blockType, text: '', toolCallId: undefined, toolCallName: '', toolCallArgs: '' }
|
|
584
|
+
partials.set(index, p)
|
|
585
|
+
order.push(index)
|
|
586
|
+
}
|
|
587
|
+
return p
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const finalizeSlot = (index) => {
|
|
591
|
+
const p = partials.get(index)
|
|
592
|
+
if (!p) return
|
|
593
|
+
if (p.assembled) return // block-end already settled it
|
|
594
|
+
if (p.blockType === 'text') {
|
|
595
|
+
blocks.push({ type: 'text', text: p.text })
|
|
596
|
+
text += p.text
|
|
597
|
+
} else if (p.blockType === 'reasoning') {
|
|
598
|
+
if (p.text !== '') blocks.push({ type: 'reasoning', text: p.text })
|
|
599
|
+
} else if (p.blockType === 'tool-call') {
|
|
600
|
+
blocks.push({
|
|
601
|
+
type: 'tool-call',
|
|
602
|
+
toolCallId: p.toolCallId,
|
|
603
|
+
name: p.toolCallName,
|
|
604
|
+
arguments: p.toolCallArgs,
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
p.assembled = true
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const flushOpenSlots = () => {
|
|
611
|
+
// Close any slot that received content but never got a `block-end`
|
|
612
|
+
// (lenient tail-handling). Only slots that actually hold data matter.
|
|
613
|
+
for (const idx of order) {
|
|
614
|
+
const p = partials.get(idx)
|
|
615
|
+
if (p && !p.assembled && (p.text !== '' || p.toolCallArgs !== '')) finalizeSlot(idx)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// TEMPORARY CHUNK-SHAPE PROBE: when a stream finishes yet produced NO text
|
|
620
|
+
// blocks, record the raw shape (type + keys + first 160 chars) of the FIRST
|
|
621
|
+
// few chunks. Self-limiting to ≤3 chunks so it cannot spam on a long stream.
|
|
622
|
+
// Guarded: logging never propagates.
|
|
623
|
+
let probeBuf = []
|
|
624
|
+
const recording = Boolean(opts && opts.recordOnEmpty)
|
|
625
|
+
let chunkCount = 0
|
|
626
|
+
// Tracks whether a REAL terminal `finish` chunk was observed (vs. the
|
|
627
|
+
// end-of-stream synthesis below). Consumed by the caller to keep its
|
|
628
|
+
// diagnostics honest: "clean natural termination with no explicit finish
|
|
629
|
+
// marker" vs. "explicit finish marker present" is a materially different
|
|
630
|
+
// failure class for debugging.
|
|
631
|
+
let sawExplicitFinish = false
|
|
632
|
+
|
|
633
|
+
for await (const rawChunk of stream) {
|
|
634
|
+
if (signal !== undefined && signal.aborted) break
|
|
635
|
+
// Defensive: a malformed stream may yield null/undefined/non-object items.
|
|
636
|
+
// Count them (so the "consumed N chunks" diagnostic stays accurate) but skip
|
|
637
|
+
// their processing — reading `.type` on a non-object would throw.
|
|
638
|
+
const chunk = (rawChunk !== null && typeof rawChunk === 'object') ? rawChunk : undefined
|
|
639
|
+
chunkCount++
|
|
640
|
+
if (chunk === undefined) continue
|
|
641
|
+
if (recording && probeBuf.length < 3 && chunkCount <= 3) {
|
|
642
|
+
// Best-effort shape capture; a non-serializable chunk must not abort the
|
|
643
|
+
// collection, so stringify is guarded and falls back to a placeholder.
|
|
644
|
+
let sample
|
|
645
|
+
try { sample = JSON.stringify(chunk).slice(0, 160) } catch { sample = '<unserializable>' }
|
|
646
|
+
probeBuf.push({ type: chunk.type, keys: Object.keys(chunk).join(','), sample })
|
|
647
|
+
}
|
|
648
|
+
switch (chunk.type) {
|
|
649
|
+
case 'block-start':
|
|
650
|
+
ensure(chunk.index, chunk.blockType)
|
|
651
|
+
break
|
|
652
|
+
case 'text-delta': {
|
|
653
|
+
const p = ensure(chunk.index, 'text')
|
|
654
|
+
if (!p.assembled) p.text += typeof chunk.text === 'string' ? chunk.text : ''
|
|
655
|
+
break
|
|
656
|
+
}
|
|
657
|
+
case 'reasoning-delta': {
|
|
658
|
+
const p = ensure(chunk.index, 'reasoning')
|
|
659
|
+
if (!p.assembled) p.text += typeof chunk.text === 'string' ? chunk.text : ''
|
|
660
|
+
break
|
|
661
|
+
}
|
|
662
|
+
case 'tool-call-delta': {
|
|
663
|
+
const p = ensure(chunk.index, 'tool-call')
|
|
664
|
+
if (!p.assembled) {
|
|
665
|
+
if (chunk.id !== undefined) p.toolCallId = chunk.id
|
|
666
|
+
if (chunk.name) p.toolCallName = chunk.name
|
|
667
|
+
if (typeof chunk.argumentsDelta === 'string') p.toolCallArgs += chunk.argumentsDelta
|
|
668
|
+
}
|
|
669
|
+
break
|
|
670
|
+
}
|
|
671
|
+
case 'block-end': {
|
|
672
|
+
// Authoritative settlement: the adapter hands the COMPLETE block. Take
|
|
673
|
+
// it verbatim rather than trusting accumulated deltas.
|
|
674
|
+
const b = chunk.block
|
|
675
|
+
if (b && b.type) {
|
|
676
|
+
if (b.type === 'text' && typeof b.text === 'string') text += b.text
|
|
677
|
+
if (b.type === 'image' || b.mediaType !== undefined) hasImage = true
|
|
678
|
+
blocks.push(b)
|
|
679
|
+
}
|
|
680
|
+
const p = ensure(chunk.index, b && b.type ? b.type : 'text')
|
|
681
|
+
p.assembled = true
|
|
682
|
+
break
|
|
683
|
+
}
|
|
684
|
+
case 'usage':
|
|
685
|
+
// THE STREAM CHARTER IS THE SOURCE OF TRUTH FOR TERMINATION (the
|
|
686
|
+
// protocol's invariant spec tests pin chunk-order violations like
|
|
687
|
+
// "usage after terminal finish", implying a well-formed stream always
|
|
688
|
+
// ends with a `finish` chunk). BUT `collectChunks` ALSO accepts streams
|
|
689
|
+
// that terminate WITHOUT one (its `no-finish` branch), so treat the
|
|
690
|
+
// END-OF-STREAM AS A TERMINAL FACT too: if the provider (typically a
|
|
691
|
+
// proxy that relays the upstream's `finish_reason`/`usage` verbatim)
|
|
692
|
+
// delivers a `usage` chunk and then simply CLOSES — the exact
|
|
693
|
+
// observed live symptom of an all-reasoning summary arriving as
|
|
694
|
+
// `reasoning-delta`s followed by `usage` and then silence — record it
|
|
695
|
+
// as a NORMAL completion rather than a protocol violation. This
|
|
696
|
+
// converts what used to be classified `no-finish` (→ arming the
|
|
697
|
+
// failure cooldown and burning the locked bracket) into a chance for
|
|
698
|
+
// the TEXT-EXTRACTION layer to salvage whatever usable output the
|
|
699
|
+
// stream DID produce. A genuine hung stream (never reaching the end,
|
|
700
|
+
// hence never seeing a `usage` chunk either) still surfaces as
|
|
701
|
+
// `no-finish`.
|
|
702
|
+
usage = chunk.usage
|
|
703
|
+
break
|
|
704
|
+
// `response-metadata`: OPENAI-compatible adapters emit this near the end
|
|
705
|
+
// of a response to carry provider-level facts (request id, completion
|
|
706
|
+
// details, …). It is neither block nor terminal information — count and
|
|
707
|
+
// ignore it explicitly so the default branch never masks a future
|
|
708
|
+
// chunk-kind addition that we silently drop.
|
|
709
|
+
case 'response-metadata':
|
|
710
|
+
break
|
|
711
|
+
case 'finish':
|
|
712
|
+
// The RAW `finish` chunk carries `reason` — a `FinishReason`
|
|
713
|
+
// `{ kind, failure? }` — directly (NOT a nested re-wrap). Store it as-is
|
|
714
|
+
// so the caller can read `finish.kind` / `finish.failure` per the
|
|
715
|
+
// protocol, matching the official `BlockAssembler` (`_finish =
|
|
716
|
+
// chunk.reason`). Previously this branch re-wrapped into a synthetic
|
|
717
|
+
// `{kind, reason, failure}` shape, losing the discriminator and making
|
|
718
|
+
// every terminal finish classify as SUCCESS → spurious "no usable text".
|
|
719
|
+
finish = chunk.reason
|
|
720
|
+
sawExplicitFinish = true
|
|
721
|
+
break
|
|
722
|
+
case 'reasoning-chunks':
|
|
723
|
+
// Legacy/aggregate reasoning variant (not in the core protocol but seen
|
|
724
|
+
// on some routes): fold each element into a reasoning block.
|
|
725
|
+
if (Array.isArray(chunk.chunks)) {
|
|
726
|
+
for (const c of chunk.chunks) {
|
|
727
|
+
if (typeof c === 'string') blocks.push({ type: 'reasoning', text: c })
|
|
728
|
+
else if (c && typeof c.text === 'string') blocks.push({ type: 'reasoning', text: c.text })
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
break
|
|
732
|
+
default:
|
|
733
|
+
break
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
flushOpenSlots()
|
|
737
|
+
// SYNTHESIZED TERMINATION (see the `usage` case): a stream that ran to the
|
|
738
|
+
// very end — evidenced by an OBSERVED trailing `usage` chunk — yet never
|
|
739
|
+
// delivered an explicit `finish` chunk terminated NORMALLY (a provider or
|
|
740
|
+
// relay chose to close without the marker, typically right after handing
|
|
741
|
+
// over usage). Record it as a clean `stop` so the text-extraction layer
|
|
742
|
+
// gets a chance to salvage real output, instead of the caller classifying
|
|
743
|
+
// the run as a protocol-violating `no-finish` and arming the failure
|
|
744
|
+
// cooldown on top. The explicit `note` marks the distinction for
|
|
745
|
+
// diagnostics without lying about where the terminator came from. A truly
|
|
746
|
+
// HUNG stream (never reaching the end, hence never observing a `usage`
|
|
747
|
+
// chunk either) still falls through as a bona-fide `no-finish`.
|
|
748
|
+
if (finish === undefined && usage !== undefined) {
|
|
749
|
+
finish = { kind: 'stop', note: 'synthesized: stream closed after a usage chunk without an explicit finish chunk' }
|
|
750
|
+
}
|
|
751
|
+
// CHUNK-SHAPE PROBE emission: only when the caller asked to record AND this
|
|
752
|
+
// run yielded zero text blocks — i.e. the exact failure signature ("produced
|
|
753
|
+
// no usable text"). Fully self-contained and guarded: NOTHING in this block
|
|
754
|
+
// may throw out of `collectChunks`, so serialization is try/catch'd and the
|
|
755
|
+
// whole emission is a no-op on any anomaly.
|
|
756
|
+
// NOTE: the bulk CHUNK-SHAPE emission that used to live here was REMOVED —
|
|
757
|
+
// it fired on every no-text run with byte-identical payloads (the upstream
|
|
758
|
+
// error is deterministic) and flooded the dev-server log. The raw chunk
|
|
759
|
+
// shapes are STILL recorded into `probeBuf`; a targeted probe can be
|
|
760
|
+
// re-enabled via `recordOnEmpty` should a genuinely NEW failure shape ever
|
|
761
|
+
// appear. The persistent `CRASH-HARNESS` line emitted on terminal
|
|
762
|
+
// `kind:'error'` finishes (in `summarize`) now carries the discriminating
|
|
763
|
+
// detail instead.
|
|
764
|
+
// `_chunkCount` is exposed (underscore-prefixed, internal) purely so a
|
|
765
|
+
// missing-finish diagnostic can distinguish "consumed N chunks but never a
|
|
766
|
+
// terminal finish" from "consumed ZERO chunks (silent/lazy/no-op stream)";
|
|
767
|
+
// `_explicitFinish` additionally lets the caller tell a SYNTHESIZED
|
|
768
|
+
// terminator apart from a real terminal finish chunk when rendering its
|
|
769
|
+
// diagnostics.
|
|
770
|
+
return { blocks, text, hasImage, finish, usage, _chunkCount: chunkCount, _explicitFinish: sawExplicitFinish }
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Render a terse, human-readable description of whatever object `stream` actually
|
|
775
|
+
* is — used ONLY in error paths to identify who replaced the expected async
|
|
776
|
+
* iterable. Never throws: any inspection failure degrades to a generic tag.
|
|
777
|
+
* @param {*} stream the object passed where an `AsyncIterable` was expected.
|
|
778
|
+
* @returns {string} e.g. `a Promise (constructor Promise)`, `a Generator`, `undefined`, `an array of length 3`.
|
|
779
|
+
*/
|
|
780
|
+
function describeStream(stream) {
|
|
781
|
+
try {
|
|
782
|
+
if (stream === undefined) return 'undefined'
|
|
783
|
+
if (stream === null) return 'null'
|
|
784
|
+
const ctor = (stream.constructor && stream.constructor.name) || typeof stream
|
|
785
|
+
if (ctor === 'Promise') return 'a Promise (constructor Promise) — a listener likely wrapped the stream in an async fn'
|
|
786
|
+
if (Array.isArray(stream)) return `an array of length ${stream.length} — a listener likely returned a pre-materialized chunk array`
|
|
787
|
+
const hasAsyncIter = typeof stream[Symbol.asyncIterator] === 'function'
|
|
788
|
+
const hasSyncIter = typeof stream[Symbol.iterator] === 'function'
|
|
789
|
+
const keys = Object.prototype.toString.call(stream)
|
|
790
|
+
return `constructor=${ctor} ${keys} asyncIterable=${hasAsyncIter} syncIterable=${hasSyncIter}`
|
|
791
|
+
} catch {
|
|
792
|
+
return `<undescribable object (${typeof stream})>`
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Filter the assembled content blocks down to TEXT ONLY and refuse anything
|
|
798
|
+
* that is structurally unsafe to land as a checkpoint (images). Reasoning
|
|
799
|
+
* blocks are dropped intentionally: they are collapsible UI regions, never
|
|
800
|
+
* durable checkpoint content.
|
|
801
|
+
* @param {Array} blocks
|
|
802
|
+
* @returns {Array<{type:'text', text:string}>}
|
|
803
|
+
*/
|
|
804
|
+
function extractTextOnly(blocks) {
|
|
805
|
+
return (blocks || []).filter(b => b && b.type === 'text' && typeof b.text === 'string')
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Coerce a provider failure fact to a short human-readable message. */
|
|
809
|
+
function failureText(failure) {
|
|
810
|
+
if (failure === undefined || failure === null) return ''
|
|
811
|
+
if (typeof failure === 'string') return failure
|
|
812
|
+
return failure.message || failure.description || ''
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Length of the concatenated text blocks (0 when none). */
|
|
816
|
+
function measuredLength(text) {
|
|
817
|
+
return typeof text === 'string' ? text.length : 0
|
|
818
|
+
}
|