@quekenao/dsh-auto-continue-community 0.0.0-stage → 0.3.2

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/index.js ADDED
@@ -0,0 +1,1024 @@
1
+ /**
2
+ * dsh-auto-continue-community
3
+ *
4
+ * Automatically continues a model response when it is cut off at the output
5
+ * token limit (finish.kind === "max-tokens"), so the agent loop receives a
6
+ * complete answer without the user having to type "继续".
7
+ *
8
+ * Two operating modes (config `mode`, default "loop"):
9
+ *
10
+ * loop (REALTIME, default)
11
+ * The plugin does NOT buffer the model stream. The agent loop streams
12
+ * tokens to the UI in real time (dsh-agent-loop streams chunks live and
13
+ * detects max-tokens at the RESULT level). The plugin observes the
14
+ * `agent/assistant-stream` frames; when a step's stream ends with a
15
+ * `finish` chunk of kind `max-tokens`, it calls `agent.steer(continuationMsg)`
16
+ * to queue a continuation prompt as the NEXT step of the SAME turn. The
17
+ * agent loop's turn loop keeps the turn open while `inbox.nextStep` has
18
+ * work, so the continuation runs immediately after the truncated step,
19
+ * still streaming in real time. Truncation detection is by reading the
20
+ * finish chunk's `reason.kind` from the stream frames.
21
+ *
22
+ * stream (BUFFERED, fallback / legacy)
23
+ * The plugin intercepts the `llm/stream` waterfall, buffers the whole
24
+ * upstream stream, detects max-tokens from the final finish chunk,
25
+ * re-invokes the model with a continuation prompt, and merges the two
26
+ * streams into one. Because the finish reason only arrives at
27
+ * stream-end, the entire upstream stream is held before forwarding, so
28
+ * tokens do NOT appear in the UI in real time — they appear all at once
29
+ * after the stream (and any continuation) completes. This is the
30
+ * original edition's behavior.
31
+ *
32
+ * The loop mode is the "top-tier" upgrade: real-time token display. The
33
+ * stream mode is retained as a guaranteed fallback — if the loop mode
34
+ * misbehaves in a given deployment, set `mode: "stream"` (or
35
+ * DSH_AUTOCONTINUE_MODE=stream) to fall back to the known-good buffering
36
+ * path. The two modes are mutually exclusive at load time; the plugin
37
+ * registers only the listeners for the active mode.
38
+ *
39
+ * Tool-call handling:
40
+ * - Complete tool calls (block-end received) are safe: DSH discards them
41
+ * on max-tokens finish, so the continuation simply resumes the text
42
+ * that was cut off after them.
43
+ * - Incomplete tool calls (delta without block-end, i.e. JSON arguments
44
+ * truncated mid-stream) are NOT continued — the model cannot repair
45
+ * malformed JSON.
46
+ *
47
+ * Design invariants:
48
+ * - Activates only for the configured provider(s). Cloud API providers
49
+ * have their own pipeline optimizations and must not be intercepted.
50
+ * The provider gate is configurable via the patch `config` block or the
51
+ * `DSH_AUTOCONTINUE_PROVIDERS` environment variable (comma-separated);
52
+ * defaults to "local-vision".
53
+ * - Per-session continuation counter resets on every non-truncated
54
+ * response; capped at maxContinuations per single response chain.
55
+ * - Continuation re-entrancy is detected via a non-enumerable string-key
56
+ * property (CONTINUATION_MARK) on the continuation message, invisible to
57
+ * JSON.stringify and spread, so it is safe for any downstream serializer.
58
+ * - If the continuation fails, the plugin degrades to the original
59
+ * truncated response.
60
+ *
61
+ * Community edition differences vs the internal `dsh-auto-continue`:
62
+ * - Debug log path is portable (OS temp dir + plugin name), never a
63
+ * hardcoded absolute path.
64
+ * - Provider gate, continuation budget, timeout AND mode are configurable.
65
+ * - Real-time loop mode (default) + buffered stream mode (fallback).
66
+ * - Pure helper functions (analyzeChunks, mergeChunks, sumUsage, the
67
+ * session-counter store, the loop-state store, the continuation-gate
68
+ * decision) are named exports so they can be unit-tested in isolation
69
+ * (see test/).
70
+ *
71
+ * SCOPE / COMPATIBILITY (read before deploying):
72
+ *
73
+ * This plugin is ONLY validated for the following stack. It will not
74
+ * necessarily behave correctly outside it.
75
+ *
76
+ * Topology: DSH + llama.cpp + a local model
77
+ * Versions: DSH 0.2.1-rc.5 · @deepseek-ai/cordis ~4.0.5
78
+ * @deepseek-ai/dsh-llm 0.2.1-rc.5
79
+ * @deepseek-ai/dsh-agent-loop 0.2.1-rc.5
80
+ * @deepseek-ai/dsh-web-app 0.2.1-rc.5
81
+ * Node.js >= 24 (developed/tested on v24.18.0)
82
+ * Surface: Web surface ONLY (`dsh web`). The loop mode observes the
83
+ * `agent/assistant-stream` emit event and drives the agent
84
+ * driver's `steer()` (next-step inbox) to inject the
85
+ * continuation; the stream mode uses the LlmRuntime
86
+ * `llm/stream` waterfall. The client / CLI surface is NOT
87
+ * adapted and is NOT supported by this edition — we do not
88
+ * ship client support.
89
+ *
90
+ * The `llm/stream` waterfall hook, the BlockAssembler block-indexing
91
+ * convention, the `agent/pre-step` decision shape, and the max-tokens
92
+ * tool-call discard behavior it relies on are specific to the DSH
93
+ * 0.2.1-rc.5 line. A different DSH major version, a non-llama.cpp
94
+ * backend, or the client surface are out of scope.
95
+ */
96
+
97
+ import { z } from "zod";
98
+ import { randomUUID } from "node:crypto";
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Defaults (overridable via patch `config` block or environment variables).
102
+ // ---------------------------------------------------------------------------
103
+
104
+ const DEFAULT_MAX_CONTINUATIONS = 5;
105
+ const DEFAULT_CONTINUATION_TIMEOUT_MS = 120_000;
106
+ const DEFAULT_PROVIDERS = ['local-vision'];
107
+ const DEFAULT_MODE = 'loop'; // 'loop' (realtime, default) | 'stream' (buffered fallback)
108
+ const VALID_MODES = new Set(['loop', 'stream']);
109
+
110
+ // String-key marker on continuation messages.
111
+ // NOTE: must be ENUMERABLE — DSH's session layer uses Reflect.ownKeys +
112
+ // propertyIsEnumerable (dsh-util-values/walkJsonValue) and rejects any
113
+ // object with non-enumerable own properties as "non-JSON-serializable".
114
+ // The key is a string (not a Symbol) so it survives JSON round-trips and
115
+ // is robust against future DSH serialization changes. It is harmless in
116
+ // the session log: it's a top-level boolean on the message envelope, not
117
+ // inside the content the LLM sees.
118
+ export const CONTINUATION_MARK = '__dsh_auto_continue_continuation__';
119
+
120
+ const CONTINUE_PROMPT_TEXT =
121
+ '[CONTINUATION REQUIRED] Your previous response was truncated at the output token limit. ' +
122
+ 'Continue from EXACTLY where you stopped. Do NOT repeat any text, do NOT add a preamble, ' +
123
+ 'do NOT apologize. Simply resume the content that was cut off.';
124
+
125
+ // Used when the truncated response contained complete tool calls.
126
+ // DSH discards ALL tool-call blocks when finish.kind === "max-tokens"
127
+ // (dsh-llm/lib/index.js: the kept filter drops them before the agent
128
+ // loop sees them), so the agent has no tool blocks in its message
129
+ // history. The prompt therefore describes the situation factually and
130
+ // asks the model to continue its narrative text without re-issuing
131
+ // tool calls or referring to tool results that do not exist.
132
+ const CONTINUE_PROMPT_TEXT_WITH_TOOLCALLS =
133
+ '[CONTINUATION REQUIRED] Your previous response was truncated at the output token limit. ' +
134
+ 'You had already emitted one or more tool calls before the text was cut off. ' +
135
+ 'Those tool calls have NOT been executed and are NOT in your context. ' +
136
+ 'Do NOT repeat them, do NOT refer to their results, and do NOT issue new tool calls. ' +
137
+ 'Continue the narrative text that was cut off — resume EXACTLY from where you stopped. ' +
138
+ 'Do NOT add a preamble, do NOT apologize.';
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Session continuation counter store (named export for tests).
142
+ //
143
+ // A Map used for LRU ordering: every access (get/set) moves the key to the
144
+ // end, so `prune` can evict from the front (least-recently-used).
145
+ // ---------------------------------------------------------------------------
146
+
147
+ export const sessionCounters = new Map();
148
+
149
+ export function counterKey(options) {
150
+ if (!options || !options.sessionId) return '__default__';
151
+ return String(options.sessionId);
152
+ }
153
+
154
+ /**
155
+ * Get the counter for `key`, touching it (moves to most-recently-used).
156
+ * Creates an entry at 0 if absent.
157
+ */
158
+ export function getCounter(key) {
159
+ let n = sessionCounters.get(key);
160
+ if (n === undefined) {
161
+ n = 0;
162
+ sessionCounters.set(key, n);
163
+ } else {
164
+ // Touch: re-insert to move to the MRU end.
165
+ sessionCounters.delete(key);
166
+ sessionCounters.set(key, n);
167
+ }
168
+ return n;
169
+ }
170
+
171
+ export function incCounter(key) {
172
+ const n = getCounter(key) + 1;
173
+ sessionCounters.set(key, n);
174
+ return n;
175
+ }
176
+
177
+ export function resetCounter(key) {
178
+ const cur = sessionCounters.get(key);
179
+ if (cur !== undefined) {
180
+ sessionCounters.delete(key);
181
+ }
182
+ sessionCounters.set(key, 0);
183
+ }
184
+
185
+ /**
186
+ * Prune stale counters in long-running processes. At most once per
187
+ * `minIntervalMs`, only when the map exceeds `maxEntries`. Evicts from the
188
+ * front of the Map (least-recently-used), keeping the `keep` most recently
189
+ * active. Returns the number of entries evicted.
190
+ */
191
+ let lastPrune = 0;
192
+ export function pruneCounters(now = Date.now(), { maxEntries = 256, keep = 128, minIntervalMs = 60_000 } = {}) {
193
+ if (sessionCounters.size <= maxEntries || now - lastPrune < minIntervalMs) return 0;
194
+ lastPrune = now;
195
+ const keys = [...sessionCounters.keys()];
196
+ let evicted = 0;
197
+ for (let i = 0; i < keys.length - keep; i++) {
198
+ sessionCounters.delete(keys[i]);
199
+ evicted++;
200
+ }
201
+ return evicted;
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Stream analysis / merge helpers (named exports for tests). Used by the
206
+ // "stream" (buffered) mode. The "loop" (realtime) mode does not buffer, so
207
+ // it does not call these on the upstream stream.
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * Buffer an async iterable (or sync iterable, for Node 24+ compat) of
212
+ * StreamChunks into an array.
213
+ *
214
+ * Node 24 removed implicit sync-iterable support in `for await...of`.
215
+ * Some LlmAdapter implementations may return a sync iterable from
216
+ * `stream()`. Detect and handle both.
217
+ */
218
+ export async function collectChunks(stream) {
219
+ const chunks = [];
220
+ if (!stream) {
221
+ throw new TypeError('collectChunks: stream is undefined/null');
222
+ }
223
+ if (typeof stream[Symbol.asyncIterator] === 'function') {
224
+ for await (const chunk of stream) {
225
+ chunks.push(chunk);
226
+ }
227
+ return chunks;
228
+ }
229
+ if (typeof stream[Symbol.iterator] === 'function') {
230
+ for (const chunk of stream) {
231
+ chunks.push(chunk);
232
+ }
233
+ return chunks;
234
+ }
235
+ if (typeof stream.next === 'function') {
236
+ let r;
237
+ do {
238
+ r = await stream.next();
239
+ if (r && !r.done) chunks.push(r.value);
240
+ } while (!(r && r.done));
241
+ return chunks;
242
+ }
243
+ throw new TypeError('collectChunks: not an iterable. keys=' + Object.keys(stream).join(',') + ' proto=' + Object.getOwnPropertyNames(Object.getPrototypeOf(stream) || {}).join(','));
244
+ }
245
+
246
+ /**
247
+ * Normalize a finish reason to a canonical kind string.
248
+ * Handles: { kind: 'max-tokens' }, { reason: 'max_tokens' },
249
+ * or the plain string 'max-tokens' / 'max_tokens'.
250
+ */
251
+ export function finishKind(chunk) {
252
+ const r = chunk.reason;
253
+ if (typeof r === 'string') return r.replace(/[_-]/g, '-');
254
+ if (r && typeof r === 'object') {
255
+ const k = r.kind || r.reason;
256
+ if (typeof k === 'string') return k.replace(/[_-]/g, '-');
257
+ }
258
+ return '';
259
+ }
260
+
261
+ /**
262
+ * Scan a chunk array and classify tool-call blocks by completion state.
263
+ *
264
+ * Block identity is by `chunk.index`, mirroring BlockAssembler
265
+ * (dsh-llm/lib/types/assembler.js): a tool-call block is "opened" by its
266
+ * first tool-call-delta (or block-start) and "closed" by the first
267
+ * block-end for that index with block.type === 'tool-call'. Deltas
268
+ * after close are ignored.
269
+ *
270
+ * Returns:
271
+ * - truncated: any finish chunk has kind "max-tokens"
272
+ * - hasCompleteToolCall: at least one tool-call block was closed
273
+ * (block-end received) — DSH discards these on max-tokens finish,
274
+ * so a continuation that resumes the following text is safe.
275
+ * - hasIncompleteToolCall: at least one tool-call block was opened
276
+ * (deltas seen) but never closed — JSON arguments are malformed
277
+ * and continuation cannot repair them.
278
+ * - hasToolCall: union of the two (kept for log readability).
279
+ * - finishIdx: index of the LAST finish chunk, or -1.
280
+ */
281
+ export function analyzeChunks(chunks) {
282
+ let truncated = false;
283
+ let hasCompleteToolCall = false;
284
+ let hasIncompleteToolCall = false;
285
+ let finishIdx = -1;
286
+ let missingIndexDeltas = 0;
287
+ let missingIndexBlockEnds = 0;
288
+
289
+ const closedIndexes = new Set();
290
+ const openedIndexes = new Set();
291
+
292
+ for (let i = 0; i < chunks.length; i++) {
293
+ const chunk = chunks[i];
294
+ if (!chunk || typeof chunk !== 'object') continue;
295
+
296
+ if (chunk.type === 'block-end') {
297
+ const block = chunk.block;
298
+ if (block && block.type === 'tool-call') {
299
+ if (typeof chunk.index === 'number') {
300
+ closedIndexes.add(chunk.index);
301
+ openedIndexes.add(chunk.index);
302
+ } else {
303
+ missingIndexBlockEnds++;
304
+ }
305
+ hasCompleteToolCall = true;
306
+ }
307
+ } else if (chunk.type === 'tool-call-delta') {
308
+ if (typeof chunk.index === 'number') {
309
+ openedIndexes.add(chunk.index);
310
+ } else {
311
+ missingIndexDeltas++;
312
+ }
313
+ } else if (chunk.type === 'block-start') {
314
+ if (chunk.blockType === 'tool-call' && typeof chunk.index === 'number') {
315
+ openedIndexes.add(chunk.index);
316
+ }
317
+ }
318
+
319
+ if (chunk.type === 'finish') {
320
+ finishIdx = i;
321
+ if (finishKind(chunk) === 'max-tokens') {
322
+ truncated = true;
323
+ }
324
+ }
325
+ }
326
+
327
+ if (!hasIncompleteToolCall) {
328
+ for (const idx of openedIndexes) {
329
+ if (!closedIndexes.has(idx)) {
330
+ hasIncompleteToolCall = true;
331
+ break;
332
+ }
333
+ }
334
+ }
335
+
336
+ if (!hasIncompleteToolCall && missingIndexDeltas > 0 && missingIndexBlockEnds === 0) {
337
+ hasIncompleteToolCall = true;
338
+ }
339
+
340
+ return {
341
+ truncated,
342
+ hasCompleteToolCall,
343
+ hasIncompleteToolCall,
344
+ hasToolCall: hasCompleteToolCall || hasIncompleteToolCall,
345
+ finishIdx,
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Sum two TokenUsage objects, tolerating missing fields.
351
+ * llama.cpp reports per-call usage (each call's prompt_tokens covers
352
+ * only that call's input), so a merged chain's total is the sum of
353
+ * both calls' completion_tokens. prompt_tokens takes the continuation's
354
+ * value (it includes the full message context: original messages +
355
+ * the continuation prompt), which is a more representative single
356
+ * number for display.
357
+ */
358
+ export function sumUsage(a, b) {
359
+ if (!a) return b || null;
360
+ if (!b) return a;
361
+ return {
362
+ prompt_tokens: (b.prompt_tokens || a.prompt_tokens) || 0,
363
+ completion_tokens: (a.completion_tokens || 0) + (b.completion_tokens || 0),
364
+ total_tokens: (a.completion_tokens || 0) + (b.completion_tokens || 0) + (b.prompt_tokens || a.prompt_tokens || 0),
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Build the merged chunk array:
370
+ * upstream body (minus finish) + continuation body (minus finish) + terminal finish
371
+ *
372
+ * Block indices in the continuation body are offset by the upstream's
373
+ * max block index + 1 to prevent index collisions when both streams
374
+ * contain multiple blocks (BlockAssembler keys partials by index; a
375
+ * collision would cause deltas to be dropped or mis-routed).
376
+ */
377
+ export function mergeChunks(upstream, contChunks, upstreamInfo, contInfo) {
378
+ const origBody = upstreamInfo.finishIdx >= 0
379
+ ? upstream.slice(0, upstreamInfo.finishIdx)
380
+ : upstream.slice();
381
+ const origFinish = upstreamInfo.finishIdx >= 0 ? upstream[upstreamInfo.finishIdx] : null;
382
+
383
+ const contBody = contInfo.finishIdx >= 0
384
+ ? contChunks.slice(0, contInfo.finishIdx)
385
+ : contChunks.slice();
386
+ const contFinish = contInfo.finishIdx >= 0 ? contChunks[contInfo.finishIdx] : null;
387
+
388
+ const terminalFinish = contFinish || origFinish;
389
+
390
+ let maxUpstreamBlockIdx = -1;
391
+ for (const chunk of origBody) {
392
+ if (chunk && typeof chunk === 'object' && typeof chunk.index === 'number') {
393
+ if (chunk.index > maxUpstreamBlockIdx) maxUpstreamBlockIdx = chunk.index;
394
+ }
395
+ }
396
+ const blockOffset = maxUpstreamBlockIdx + 1;
397
+
398
+ const offsetContBody = contBody.map(chunk => {
399
+ if (!chunk || typeof chunk !== 'object' || typeof chunk.index !== 'number') return chunk;
400
+ const t = chunk.type;
401
+ if (t === 'block-start' || t === 'block-end' ||
402
+ t === 'text-delta' || t === 'reasoning-delta' || t === 'tool-call-delta') {
403
+ return { ...chunk, index: chunk.index + blockOffset };
404
+ }
405
+ return chunk;
406
+ });
407
+
408
+ const merged = [...origBody, ...offsetContBody];
409
+
410
+ const usageChunks = merged.filter(c => c && c.type === 'usage');
411
+ const nonUsage = merged.filter(c => !c || c.type !== 'usage');
412
+ let mergedUsage = null;
413
+ for (const uc of usageChunks) {
414
+ mergedUsage = sumUsage(mergedUsage, uc && uc.usage);
415
+ }
416
+ if (mergedUsage) {
417
+ nonUsage.push({ type: 'usage', usage: mergedUsage });
418
+ }
419
+
420
+ if (terminalFinish) {
421
+ const safeFinish = (terminalFinish.replayState !== undefined && terminalFinish.replayState !== null)
422
+ ? { ...terminalFinish, replayState: undefined }
423
+ : terminalFinish;
424
+ nonUsage.push(safeFinish);
425
+ }
426
+ return nonUsage;
427
+ }
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Loop-mode state store (named exports for tests).
431
+ //
432
+ // Tracks, per agent, whether the PREVIOUS step ended at max-tokens. The
433
+ // realtime mode detects truncation at the RESULT level (the assistant
434
+ // message's finish reason, observed via the `agent/assistant-stream`
435
+ // frames), not by buffering the stream. When the next `agent/pre-step`
436
+ // fires and the previous step was truncated, this store decides whether to
437
+ // inject a continuation prompt as a new step.
438
+ // ---------------------------------------------------------------------------
439
+
440
+ export const loopState = new Map();
441
+
442
+ export function loopKey(agent) {
443
+ if (agent && typeof agent === 'object' && agent.id !== undefined) return String(agent.id);
444
+ return '__default__';
445
+ }
446
+
447
+ /**
448
+ * Record the finish kind for an agent after a step's stream settles.
449
+ * Called from the `agent/assistant-stream` listener when the stream ends
450
+ * (the finish frame is the last frame emitted). The value is the normalized
451
+ * finish kind string ('max-tokens', 'stop', 'tool-calls', 'error',
452
+ * 'aborted', or '' if unknown).
453
+ */
454
+ export function recordLoopFinish(agent, finishKindStr) {
455
+ const key = loopKey(agent);
456
+ const state = loopState.get(key) || { pending: false, truncated: false, count: 0 };
457
+ state.pending = false;
458
+ state.truncated = (finishKindStr === 'max-tokens');
459
+ // If the step completed cleanly (not max-tokens), the response chain is
460
+ // done; reset the budget so the next independent response can continue.
461
+ if (!state.truncated) {
462
+ state.count = 0;
463
+ }
464
+ loopState.set(key, state);
465
+ return state;
466
+ }
467
+
468
+ /**
469
+ * Mark that an agent's stream has started (the first frame is in flight).
470
+ * `pending` is true until the finish frame is observed, so a concurrent
471
+ * pre-step (which can only happen after the stream settles) will not
472
+ * misread a stale truncation flag from a previous step.
473
+ */
474
+ export function markLoopPending(agent) {
475
+ const key = loopKey(agent);
476
+ const state = loopState.get(key) || { pending: false, truncated: false, count: 0 };
477
+ state.pending = true;
478
+ loopState.set(key, state);
479
+ return state;
480
+ }
481
+
482
+ /**
483
+ * Decide whether to inject a continuation at the next `agent/pre-step`.
484
+ *
485
+ * Returns `{ continue: boolean, reason: string, promptText?: string }`.
486
+ *
487
+ * The decision is pure (no I/O) so it can be unit-tested:
488
+ * - if the agent has no recorded truncation -> no continuation.
489
+ * - if the budget is exhausted -> no continuation (reason 'budget').
490
+ * - if the last user message is already a continuation marker -> no
491
+ * continuation (re-entrancy guard, reason 'already-continuing').
492
+ * - otherwise -> continue, with the appropriate prompt text.
493
+ *
494
+ * `hasCompleteToolCall` selects the tool-call-aware prompt (DSH discards
495
+ * tool-call blocks on max-tokens finish, so the continuation must not
496
+ * re-issue them).
497
+ */
498
+ export function continuationDecision(state, { maxContinuations = 5, lastMsg = null, hasCompleteToolCall = false } = {}) {
499
+ if (!state || !state.truncated) {
500
+ return { continue: false, reason: 'not-truncated' };
501
+ }
502
+ // Re-entrancy guard: if the last user message is already our continuation
503
+ // prompt, we are already in a continuation chain — do not double-inject.
504
+ if (lastMsg && lastMsg.role === 'user' && lastMsg[CONTINUATION_MARK]) {
505
+ return { continue: false, reason: 'already-continuing' };
506
+ }
507
+ if (state.count >= maxContinuations) {
508
+ return { continue: false, reason: 'budget', count: state.count, max: maxContinuations };
509
+ }
510
+ const promptText = hasCompleteToolCall ? CONTINUE_PROMPT_TEXT_WITH_TOOLCALLS : CONTINUE_PROMPT_TEXT;
511
+ return { continue: true, reason: 'truncated', promptText, count: state.count + 1, max: maxContinuations };
512
+ }
513
+
514
+ /**
515
+ * Consume a continuation decision: if we are about to inject a
516
+ * continuation, increment the counter. Returns the new count.
517
+ */
518
+ export function consumeContinuation(agent) {
519
+ const key = loopKey(agent);
520
+ const state = loopState.get(key) || { pending: false, truncated: false, count: 0 };
521
+ state.count += 1;
522
+ loopState.set(key, state);
523
+ return state.count;
524
+ }
525
+
526
+ /**
527
+ * Clear an agent's loop state (e.g. on agent disposal, or to force a clean
528
+ * slate). Returns true if an entry was removed.
529
+ */
530
+ export function clearLoopState(agent) {
531
+ return loopState.delete(loopKey(agent));
532
+ }
533
+
534
+ /**
535
+ * Prune stale loop-state entries in long-running processes. Uses the same
536
+ * LRU convention as `pruneCounters`: the Map's insertion order is the LRU
537
+ * order, evicting from the front. At most once per `minIntervalMs`, only
538
+ * when the map exceeds `maxEntries`.
539
+ */
540
+ let lastLoopPrune = 0;
541
+ export function pruneLoopState(now = Date.now(), { maxEntries = 256, keep = 128, minIntervalMs = 60_000 } = {}) {
542
+ if (loopState.size <= maxEntries || now - lastLoopPrune < minIntervalMs) return 0;
543
+ lastLoopPrune = now;
544
+ const keys = [...loopState.keys()];
545
+ let evicted = 0;
546
+ for (let i = 0; i < keys.length - keep; i++) {
547
+ loopState.delete(keys[i]);
548
+ evicted++;
549
+ }
550
+ return evicted;
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // Configuration resolution.
555
+ // ---------------------------------------------------------------------------
556
+
557
+ /**
558
+ * Resolve the active configuration from the patch `config` block (preferred)
559
+ * and environment variables (fallback). Returns a plain object with
560
+ * `providers` (string[]), `maxContinuations` (number), `timeoutMs` (number)
561
+ * and `mode` ('loop' | 'stream').
562
+ *
563
+ * Precedence per field: patch config > env var > default.
564
+ */
565
+ export function resolveConfig(patchConfig) {
566
+ const cfg = patchConfig && typeof patchConfig === 'object' ? patchConfig : {};
567
+
568
+ let providers = DEFAULT_PROVIDERS.slice();
569
+ if (Array.isArray(cfg.providers) && cfg.providers.length > 0) {
570
+ providers = cfg.providers.map(String);
571
+ } else if (typeof cfg.providers === 'string' && cfg.providers.trim()) {
572
+ providers = cfg.providers.split(',').map(s => s.trim()).filter(Boolean);
573
+ } else if (process.env.DSH_AUTOCONTINUE_PROVIDERS) {
574
+ providers = process.env.DSH_AUTOCONTINUE_PROVIDERS.split(',').map(s => s.trim()).filter(Boolean);
575
+ }
576
+
577
+ let maxContinuations = DEFAULT_MAX_CONTINUATIONS;
578
+ if (Number.isFinite(Number(cfg.maxContinuations)) && Number(cfg.maxContinuations) >= 1) {
579
+ maxContinuations = Number(cfg.maxContinuations);
580
+ } else if (process.env.DSH_AUTOCONTINUE_MAX && Number(process.env.DSH_AUTOCONTINUE_MAX) >= 1) {
581
+ maxContinuations = Number(process.env.DSH_AUTOCONTINUE_MAX);
582
+ }
583
+
584
+ let timeoutMs = DEFAULT_CONTINUATION_TIMEOUT_MS;
585
+ if (Number.isFinite(Number(cfg.timeoutMs)) && Number(cfg.timeoutMs) > 0) {
586
+ timeoutMs = Number(cfg.timeoutMs);
587
+ } else if (process.env.DSH_AUTOCONTINUE_TIMEOUT_MS && Number(process.env.DSH_AUTOCONTINUE_TIMEOUT_MS) > 0) {
588
+ timeoutMs = Number(process.env.DSH_AUTOCONTINUE_TIMEOUT_MS);
589
+ }
590
+
591
+ let mode = DEFAULT_MODE;
592
+ if (typeof cfg.mode === 'string' && VALID_MODES.has(cfg.mode)) {
593
+ mode = cfg.mode;
594
+ } else if (process.env.DSH_AUTOCONTINUE_MODE && VALID_MODES.has(process.env.DSH_AUTOCONTINUE_MODE)) {
595
+ mode = process.env.DSH_AUTOCONTINUE_MODE;
596
+ }
597
+
598
+ return { providers, maxContinuations, timeoutMs, mode };
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // Plugin entry point.
603
+ // ---------------------------------------------------------------------------
604
+
605
+ export default {
606
+ name: 'auto-continue-community',
607
+
608
+ // Cordis config schema (standard-schema via zod). Cordis reads this off
609
+ // the plugin object (cordis lib/index.js plugin.Config) and, before
610
+ // apply() runs, validates the patch `config` block against it:
611
+ // - a missing block (cordis passes `undefined`) resolves to the
612
+ // object-level .default() below;
613
+ // - a malformed block THROWS cordis's ValidationError at load time,
614
+ // so a typo in a provider, a 0 budget, or an invalid mode fails
615
+ // loudly instead of the plugin silently doing nothing.
616
+ // The validated, default-filled object is then passed as apply()'s
617
+ // second argument.
618
+ Config: z.object({
619
+ // Provider ids to intercept. Cloud API providers manage their own
620
+ // output limits and must not be forced into continuation.
621
+ providers: z.array(z.string()).min(1).default(DEFAULT_PROVIDERS),
622
+ // Max continuation retries per single response chain.
623
+ maxContinuations: z.number().int().min(1).max(20).default(DEFAULT_MAX_CONTINUATIONS),
624
+ // Per-continuation stream timeout, in milliseconds (stream mode only;
625
+ // the loop mode's continuation is a normal agent step with the agent's
626
+ // own timeout).
627
+ timeoutMs: z.number().int().min(1).max(3_600_000).default(DEFAULT_CONTINUATION_TIMEOUT_MS),
628
+ // Operating mode: "loop" (realtime, default) or "stream" (buffered
629
+ // fallback). See the module header for the tradeoffs.
630
+ mode: z.enum(['loop', 'stream']).default(DEFAULT_MODE),
631
+ }).default({
632
+ // Object-level default: when the patch has no `config:` block, cordis
633
+ // passes `undefined` to the schema. Without this, z.object() rejects
634
+ // undefined input (field-level .default() only fills in when the input
635
+ // is an object with missing fields). With this, undefined resolves to
636
+ // the full defaults and the plugin activates normally.
637
+ providers: DEFAULT_PROVIDERS,
638
+ maxContinuations: DEFAULT_MAX_CONTINUATIONS,
639
+ timeoutMs: DEFAULT_CONTINUATION_TIMEOUT_MS,
640
+ mode: DEFAULT_MODE,
641
+ }),
642
+
643
+ async apply(ctx, config, _meta) {
644
+ const logger = ctx.logger;
645
+ // `config` is the SECOND apply() argument: cordis resolves the plugin's
646
+ // `Config` schema against the patch `config` block and passes the
647
+ // validated, default-filled value here (cordis lib/index.js:
648
+ // ctx.registry.plugin(plugin, this.options.config, ...) ->
649
+ // runtime.callback(this.ctx, this.config)). `ctx.options` does not
650
+ // exist, so reading it here would silently drop the patch config.
651
+ //
652
+ // The schema fills defaults, but resolveConfig is kept as a last-resort
653
+ // fallback (env vars / hard defaults) so the plugin still degrades
654
+ // gracefully if cordis ever passes an empty or absent config.
655
+ const cfg = resolveConfig(config);
656
+
657
+ // File-based debug logging is OFF by default (the plugin is stable; the
658
+ // log grows every LLM call and is only needed when debugging).
659
+ // Enable by setting DSH_AUTOCONTINUE_DEBUG=1 in the environment before
660
+ // launching `dsh web`. The path is portable: OS temp dir + plugin name.
661
+ const DEBUG_ON = !!process.env.DSH_AUTOCONTINUE_DEBUG;
662
+ let appendFileSync = null;
663
+ let DEBUG_LOG = null;
664
+ if (DEBUG_ON) {
665
+ try {
666
+ const fs = await import('node:fs');
667
+ const os = await import('node:os');
668
+ const path = await import('node:path');
669
+ ({ appendFileSync } = fs);
670
+ DEBUG_LOG = path.join(os.tmpdir(), 'dsh-auto-continue-community-debug.log');
671
+ appendFileSync(DEBUG_LOG, '[' + new Date().toISOString() + '] ' +
672
+ 'process started (pid ' + process.pid + '); plugin code with file logging is active\n');
673
+ } catch (_) { /* non-fatal */ }
674
+ }
675
+
676
+ function logSafe(msg) {
677
+ if (DEBUG_ON && appendFileSync && DEBUG_LOG) {
678
+ try {
679
+ const ts = new Date().toISOString();
680
+ appendFileSync(DEBUG_LOG, `[${ts}] ${msg}\n`);
681
+ } catch (_) { /* file write failure is non-fatal */ }
682
+ }
683
+ if (logger) {
684
+ logger.info('[auto-continue-community] ' + msg);
685
+ }
686
+ }
687
+
688
+ logSafe('apply() completed; mode=' + cfg.mode +
689
+ ' (providers: ' + cfg.providers.join(', ') + ', maxContinuations: ' + cfg.maxContinuations +
690
+ ', timeoutMs: ' + cfg.timeoutMs + ')');
691
+
692
+ const providerSet = new Set(cfg.providers);
693
+
694
+ // Dispatch to the active mode. Each mode registers only its own
695
+ // waterfall listeners; the two are mutually exclusive at load time.
696
+ // The loop mode is the default and the "top-tier" realtime path; the
697
+ // stream mode is the known-good buffering fallback.
698
+ if (cfg.mode === 'stream') {
699
+ registerStreamMode(ctx, cfg, logSafe, providerSet);
700
+ } else {
701
+ registerLoopMode(ctx, cfg, logSafe, providerSet, _meta);
702
+ }
703
+ },
704
+ };
705
+
706
+ // ---------------------------------------------------------------------------
707
+ // LOOP MODE (realtime, default).
708
+ //
709
+ // Does NOT buffer the model stream. The agent loop streams tokens to the
710
+ // UI in real time and detects max-tokens at the result level. This mode:
711
+ // 1. Observes the `agent/assistant-stream` emit frames to learn, per
712
+ // agent, whether the step's stream ended with a `finish` chunk whose
713
+ // `reason.kind === "max-tokens"`. In the DSH 0.2.1-rc.5 line the
714
+ // finish reason lives on the StreamChunk inside the frame
715
+ // (frame.type === 'chunk' && frame.chunk.type === 'finish'), NOT on
716
+ // the frame envelope itself.
717
+ // 2. When a max-tokens finish chunk is observed, it calls
718
+ // `agent.steer(continuationMsg)`: the agent driver queues the
719
+ // continuation prompt as the NEXT step of the CURRENT turn and wakes
720
+ // the driver. The agent loop's turn loop keeps the turn open while
721
+ // `inbox.nextStep` is non-empty (turn() only closes the turn when
722
+ // the inbox is drained), so the continuation step runs immediately
723
+ // after the truncated step — still streaming in real time.
724
+ //
725
+ // Why NOT `agent/pre-step`: after a max-tokens step-end the turn loop only
726
+ // re-enters `preStep` if `inbox.nextStep` already has work; with an empty
727
+ // inbox the turn closes and no further `agent/pre-step` fires for that
728
+ // turn. A pre-step listener that only appends a message therefore never
729
+ // runs on the truncation path. `steer()` from the stream observer is the
730
+ // channel that actually keeps the turn alive.
731
+ //
732
+ // The continuation is a clean, independent agent step — not a splice into
733
+ // the previous stream — so the UI shows two distinct assistant messages
734
+ // (the truncated one, then the continuation), each streamed live. The
735
+ // agent loop treats the continuation as a normal user-prompted step, which
736
+ // is exactly what a human typing "继续" would produce, but automated.
737
+ // ---------------------------------------------------------------------------
738
+
739
+ function registerLoopMode(ctx, cfg, logSafe, providerSet, meta) {
740
+ // Track which agents are in a continuation chain so we do not re-trigger
741
+ // on the continuation's own max-tokens (the re-entrancy guard uses the
742
+ // message marker; this set is a secondary safety net).
743
+ const activeChains = new Set();
744
+
745
+ // Provider gate: only continue for agents whose provider is configured.
746
+ //
747
+ // The `agent` object (dsh-agent-loop's ReactLoopAgent) does NOT expose a
748
+ // top-level `provider` or `route` field on the 0.2.1-rc.5 line — its
749
+ // constructor sets id / options / session / inbox / phase / scope / ctx /
750
+ // dispatch only. Reading `agent.provider` here would therefore ALWAYS
751
+ // return false and silently disable the whole loop mode. The authoritative
752
+ // provider id is the plugin's route: the agent runs through the LLM route
753
+ // that this plugin was attached to (the `llm-pi-ai` route, e.g.
754
+ // "local-vision"). We read it from the plugin `meta` (cordis
755
+ // plugin-context metadata, present when the plugin is attached to a route)
756
+ // and fall back to the agent's own fields (future-proofing) and finally to
757
+ // a fail-open default when no provider can be determined.
758
+ function pluginProvider() {
759
+ const p = meta && (meta.provider || (meta.route && meta.route.provider) || meta.id);
760
+ return (typeof p === 'string' && p) ? p : null;
761
+ }
762
+ function providerOk(agent) {
763
+ // 1) The plugin's own route provider (authoritative in this deployment).
764
+ const pp = pluginProvider();
765
+ if (pp && providerSet.has(pp)) return true;
766
+ // 2) A provider id exposed directly on the agent (future-proofing).
767
+ const ap = agent && (agent.provider || (agent.route && agent.route.provider));
768
+ if (typeof ap === 'string' && ap && providerSet.has(ap)) return true;
769
+ // 3) Fail open only when we could determine no provider at all: the
770
+ // plugin is already attached to exactly one route, so an undetermined
771
+ // provider almost certainly IS the configured one. When we DID
772
+ // determine a provider but it is not configured (e.g. a cloud
773
+ // provider), we gate it out.
774
+ if (!pp && !(typeof ap === 'string' && ap)) return true;
775
+ return false;
776
+ }
777
+ // One-shot diagnostic: log which provider-gate branch resolves the first
778
+ // time we see an agent. Confirms whether cordis passes a `meta` with a
779
+ // route/provider id (branch 1) or whether we are relying on the fail-open
780
+ // default (branch 3). Fires at most once per process.
781
+ let loggedGate = false;
782
+ function logGateOnce(agent) {
783
+ if (loggedGate) return;
784
+ loggedGate = true;
785
+ const pp = pluginProvider();
786
+ const ap = agent && (agent.provider || (agent.route && agent.route.provider));
787
+ const branch = (pp && providerSet.has(pp)) ? 'plugin-meta'
788
+ : (typeof ap === 'string' && ap && providerSet.has(ap)) ? 'agent-field'
789
+ : (!pp && !(typeof ap === 'string' && ap)) ? 'fail-open'
790
+ : 'gated-out';
791
+ logSafe('loop: providerOk branch=' + branch +
792
+ ' (meta.provider=' + JSON.stringify(pp) + ', agent.provider=' + JSON.stringify(ap) +
793
+ ', providerSet=[' + [...providerSet].join(',') + '])');
794
+ }
795
+
796
+ // Observe assistant stream frames to detect the finish reason per agent.
797
+ //
798
+ // The `agent/assistant-stream` emit event payload is { agent, frame }
799
+ // (cordis runtime-types, mode "emit"). `frame` is an AssistantStreamFrame:
800
+ // { type: 'start', attemptId, revision, turn, step }
801
+ // { type: 'chunk', attemptId, revision, index, time, chunk: StreamChunk }
802
+ // { type: 'end', attemptId, revision, index, outcome: {...} }
803
+ // The finish reason lives on the StreamChunk INSIDE a 'chunk' frame
804
+ // (frame.chunk = { type: 'finish', reason: { kind, ... } }); the frame
805
+ // envelope itself carries NO finish reason. We mark pending on the
806
+ // 'start' frame and read `reason.kind` from the finish chunk.
807
+ const disposeStream = ctx.on('agent/assistant-stream', ({ agent, frame }) => {
808
+ if (!providerOk(agent)) return;
809
+ logGateOnce(agent);
810
+ if (!frame || typeof frame !== 'object') return;
811
+
812
+ // First frame of this step: mark pending so a concurrent observer does
813
+ // not misread a stale truncation flag from a previous step.
814
+ if (frame.type === 'start' || frame.kind === 'start') {
815
+ markLoopPending(agent);
816
+ return;
817
+ }
818
+
819
+ // The finish reason is carried by a StreamChunk of type 'finish' inside
820
+ // a 'chunk' frame. Normalize the kind.
821
+ const chunk = (frame.type === 'chunk' || frame.kind === 'chunk') ? frame.chunk : null;
822
+ if (!chunk || chunk.type !== 'finish') return;
823
+ const finishReason = chunk.reason;
824
+ const kind = (typeof finishReason === 'string')
825
+ ? finishReason.replace(/[_-]/g, '-')
826
+ : ((finishReason && (finishReason.kind || finishReason.reason) || '').replace(/[_-]/g, '-'));
827
+ recordLoopFinish(agent, kind);
828
+ // Observability: log clean (non-truncated) finishes too, so "why did the
829
+ // chain stop?" is answerable from the debug log alone — without having to
830
+ // mine the durable session event log for finish chunks. A `stop` finish
831
+ // here means the model emitted EOS on its own (possibly mid-response, a
832
+ // model-side false stop); by design the plugin only continues on
833
+ // `max-tokens`, so the absence of a continuation line for this step is
834
+ // expected — this line just makes that explicit.
835
+ if (kind && kind !== 'max-tokens') {
836
+ logSafe('loop: agent ' + (agent && agent.id) +
837
+ ' step ended ' + kind + ' (not max-tokens; chain ends, no continuation)');
838
+ }
839
+ if (kind === 'max-tokens') {
840
+ logSafe('loop: agent ' + (agent && agent.id) + ' step ended max-tokens (continuation candidate)');
841
+ // Decide whether to continue and, if so, queue the continuation as the
842
+ // next step of the CURRENT turn via agent.steer(). steer() appends to
843
+ // the agent's inbox.nextStep and wakes the driver; the agent loop's
844
+ // turn() keeps the turn open while inbox.nextStep is non-empty, so the
845
+ // continuation runs immediately after this truncated step.
846
+ const state = loopState.get(loopKey(agent));
847
+ const lastMsg = (agent && Array.isArray(agent.inbox && agent.inbox.messages))
848
+ ? agent.inbox.messages[agent.inbox.messages.length - 1] : null;
849
+ const decision = continuationDecision(state, {
850
+ maxContinuations: cfg.maxContinuations,
851
+ lastMsg,
852
+ });
853
+ if (decision.continue) {
854
+ const continuationMsg = {
855
+ id: randomUUID(),
856
+ role: 'user',
857
+ content: [{ type: 'text', text: decision.promptText }],
858
+ source: { kind: 'user' },
859
+ };
860
+ Object.defineProperty(continuationMsg, CONTINUATION_MARK, {
861
+ value: true,
862
+ enumerable: true,
863
+ });
864
+ try {
865
+ if (agent && typeof agent.steer === 'function') {
866
+ agent.steer(continuationMsg);
867
+ consumeContinuation(agent);
868
+ if (agent && agent.id !== undefined) activeChains.add(agent.id);
869
+ logSafe('loop: agent ' + (agent && agent.id) + ' injecting continuation ' +
870
+ decision.count + '/' + decision.max + ' via steer()');
871
+ } else {
872
+ logSafe('loop: agent ' + (agent && agent.id) + ' agent.steer unavailable; cannot continue');
873
+ }
874
+ } catch (err) {
875
+ logSafe('loop: agent ' + (agent && agent.id) + ' steer() failed: ' + (err && err.message));
876
+ }
877
+ } else {
878
+ logSafe('loop: agent ' + (agent && agent.id) + ' max-tokens, no continuation (' + decision.reason + ')');
879
+ clearLoopState(agent);
880
+ if (agent && agent.id !== undefined) activeChains.delete(agent.id);
881
+ }
882
+ }
883
+ pruneLoopState();
884
+ });
885
+
886
+ // Clean up loop state when an agent is disposed, so we do not leak state
887
+ // for agents that no longer exist.
888
+ ctx.on('agent/dispose', ({ agent }) => {
889
+ clearLoopState(agent);
890
+ if (agent && agent.id !== undefined) activeChains.delete(agent.id);
891
+ });
892
+
893
+ // No return value needed: DSH auto-disposes plugin listeners on plugin
894
+ // unload (cordis lifecycle). The disposeStream function is called by the
895
+ // framework, not by us. `activeChains` is cleared per-agent on dispose.
896
+ }
897
+
898
+ // ---------------------------------------------------------------------------
899
+ // STREAM MODE (buffered, fallback / legacy).
900
+ //
901
+ // The original edition's behavior: intercept the `llm/stream` waterfall,
902
+ // buffer the whole upstream stream, detect max-tokens from the final finish
903
+ // chunk, re-invoke the model with a continuation prompt, and merge the two
904
+ // streams into one. Tokens do NOT appear in the UI in real time because
905
+ // the finish reason only arrives at stream-end. This mode is retained as a
906
+ // guaranteed fallback when the loop mode misbehaves in a given deployment.
907
+ // ---------------------------------------------------------------------------
908
+
909
+ function registerStreamMode(ctx, cfg, logSafe, providerSet) {
910
+ ctx.on('llm/stream', (options, next) => {
911
+ const provider = options && options.provider;
912
+ if (!provider || !providerSet.has(provider)) {
913
+ return next(); // pass-through: let the next listener / built-in handler run
914
+ }
915
+
916
+ const key = counterKey(options);
917
+ const runtime = ctx.runtime;
918
+
919
+ if (!runtime || typeof runtime.stream !== 'function') {
920
+ logSafe('stream: runtime or runtime.stream unavailable; passing through');
921
+ return next();
922
+ }
923
+
924
+ return (async function* streamHandler() {
925
+ // Buffer the entire upstream stream so we can inspect the finish
926
+ // reason. This is what makes the display non-realtime.
927
+ let upstream;
928
+ try {
929
+ upstream = await next();
930
+ } catch (err) {
931
+ logSafe('stream: next() failed: ' + (err && err.message) + '; rethrowing');
932
+ throw err;
933
+ }
934
+ const chunks = await collectChunks(upstream);
935
+ const info = analyzeChunks(chunks);
936
+
937
+ logSafe('stream: provider=' + provider + ' sessionId=' + (options.sessionId || '?') +
938
+ ' chunks=' + chunks.length + ' truncated=' + info.truncated +
939
+ ' hasCompleteToolCall=' + info.hasCompleteToolCall +
940
+ ' hasIncompleteToolCall=' + info.hasIncompleteToolCall);
941
+
942
+ // No truncation: forward as-is (reset the counter).
943
+ if (!info.truncated) {
944
+ for (const chunk of chunks) yield chunk;
945
+ resetCounter(key);
946
+ pruneCounters();
947
+ return;
948
+ }
949
+
950
+ // Incomplete tool call: cannot continue (malformed JSON).
951
+ if (info.hasIncompleteToolCall) {
952
+ logSafe('stream: incomplete tool call present; NOT continuing, forwarding truncated stream');
953
+ for (const chunk of chunks) yield chunk;
954
+ // Keep the counter so the chain is considered active, but do not
955
+ // increment (we are not actually continuing).
956
+ pruneCounters();
957
+ return;
958
+ }
959
+
960
+ // Budget exhausted.
961
+ const count = getCounter(key);
962
+ if (count >= cfg.maxContinuations) {
963
+ logSafe('stream: budget exhausted (' + count + '/' + cfg.maxContinuations + '); forwarding truncated stream');
964
+ for (const chunk of chunks) yield chunk;
965
+ pruneCounters();
966
+ return;
967
+ }
968
+
969
+ // Build the continuation prompt.
970
+ const contText = info.hasCompleteToolCall ? CONTINUE_PROMPT_TEXT_WITH_TOOLCALLS : CONTINUE_PROMPT_TEXT;
971
+ const continuationMessage = {
972
+ id: randomUUID(),
973
+ role: 'user',
974
+ content: [{ type: 'text', text: contText }],
975
+ };
976
+ Object.defineProperty(continuationMessage, CONTINUATION_MARK, {
977
+ value: true,
978
+ enumerable: true,
979
+ });
980
+
981
+ // Re-invoke the model with the continuation prompt.
982
+ const continuationOptions = {
983
+ ...options,
984
+ messages: [...(options.messages || []), continuationMessage],
985
+ maxContinuations: cfg.maxContinuations,
986
+ };
987
+
988
+ let contChunks;
989
+ try {
990
+ const contStream = await runtime.stream(continuationOptions);
991
+ contChunks = await collectChunks(contStream);
992
+ } catch (err) {
993
+ logSafe('stream: continuation FAILED: ' + (err && err.message) + '; degrading to truncated stream');
994
+ for (const chunk of chunks) yield chunk;
995
+ // Keep counter (chain active) so the next call knows.
996
+ pruneCounters();
997
+ return;
998
+ }
999
+
1000
+ const contInfo = analyzeChunks(contChunks);
1001
+
1002
+ if (contInfo.hasIncompleteToolCall) {
1003
+ logSafe('stream: continuation had incomplete tool call; forwarding combined (upstream + continuation body)');
1004
+ }
1005
+ if (contInfo.truncated) {
1006
+ logSafe('stream: continuation also truncated at max-tokens; yielding combined stream');
1007
+ }
1008
+
1009
+ const merged = mergeChunks(chunks, contChunks, info, contInfo);
1010
+
1011
+ logSafe('stream: merged ' + (info.finishIdx >= 0 ? info.finishIdx : chunks.length) +
1012
+ ' upstream + ' + (contInfo.finishIdx >= 0 ? contInfo.finishIdx : contChunks.length) +
1013
+ ' continuation chunks');
1014
+
1015
+ for (const chunk of merged) yield chunk;
1016
+
1017
+ // Reset the counter if the chain ended cleanly.
1018
+ if (!contInfo.truncated) {
1019
+ resetCounter(key);
1020
+ }
1021
+ pruneCounters();
1022
+ })();
1023
+ });
1024
+ }