@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/LICENSE +21 -0
- package/README.md +592 -2
- package/cordis.patch.yml +30 -0
- package/index.js +1024 -0
- package/package.json +62 -4
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
|
+
}
|