dsh-advisor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +7 -0
- package/README.md +303 -0
- package/README.zh.md +166 -0
- package/cordis.patch.yml +6 -0
- package/lib/advisor-runtime.d.ts +242 -0
- package/lib/advisor-runtime.js +662 -0
- package/lib/advisor-runtime.js.map +1 -0
- package/lib/client/advisor-card.d.ts +90 -0
- package/lib/client/advisor-store.d.ts +310 -0
- package/lib/client/index.d.ts +39 -0
- package/lib/client/locales.d.ts +40 -0
- package/lib/client.d.ts +1 -0
- package/lib/client.js +840 -0
- package/lib/commands.d.ts +136 -0
- package/lib/commands.js +185 -0
- package/lib/commands.js.map +1 -0
- package/lib/config.d.ts +74 -0
- package/lib/config.js +93 -0
- package/lib/config.js.map +1 -0
- package/lib/delivery.d.ts +129 -0
- package/lib/delivery.js +169 -0
- package/lib/delivery.js.map +1 -0
- package/lib/emission-guard.d.ts +99 -0
- package/lib/emission-guard.js +155 -0
- package/lib/emission-guard.js.map +1 -0
- package/lib/gateway.d.ts +116 -0
- package/lib/gateway.js +214 -0
- package/lib/gateway.js.map +1 -0
- package/lib/index.d.ts +48 -0
- package/lib/index.js +485 -0
- package/lib/index.js.map +1 -0
- package/lib/kinds.d.ts +38 -0
- package/lib/kinds.js +24 -0
- package/lib/kinds.js.map +1 -0
- package/lib/prompts.d.ts +22 -0
- package/lib/prompts.js +38 -0
- package/lib/prompts.js.map +1 -0
- package/lib/settings.d.ts +96 -0
- package/lib/settings.js +141 -0
- package/lib/settings.js.map +1 -0
- package/lib/transcript.d.ts +257 -0
- package/lib/transcript.js +530 -0
- package/lib/transcript.js.map +1 -0
- package/package.json +90 -0
- package/scripts/build-client.mjs +268 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session advisor runtime (spec §2 S2, §4 mapping rows, §6, §8.2 KD-2,
|
|
3
|
+
* §8.5 KD-5) — the "advisor model call + note extraction + drain/backlog"
|
|
4
|
+
* core.
|
|
5
|
+
*
|
|
6
|
+
* One {@link AdvisorRuntime} exists per session (created on `agent/created` or
|
|
7
|
+
* lazily on the first stepped `turn/end`, disposed on `agent/disposed` /
|
|
8
|
+
* `session/disposed` — wired in `index.ts`). It owns:
|
|
9
|
+
*
|
|
10
|
+
* - a FIFO queue of pending transcript deltas (bounded — spec §6 "bounded
|
|
11
|
+
* backlog"; drop-newest when full);
|
|
12
|
+
* - a serialized async drain loop: one `llm.stream` call per delta with
|
|
13
|
+
* `{ provider, model, system, messages: [user delta], maxTokens: 5120 }` and
|
|
14
|
+
* `purpose` left UNSET (KD-5 — an advisor call is an ordinary conversation
|
|
15
|
+
* request);
|
|
16
|
+
* - a call-level deadline on every `llm.stream` call (dsh-timeout `deadline`,
|
|
17
|
+
* fused with the dispose signal and raced per chunk): a hung provider stream
|
|
18
|
+
* times out instead of wedging the drain, and a timeout is a transient
|
|
19
|
+
* failure (KD-5 retry → drop);
|
|
20
|
+
* - KD-2 JSON-frame extraction: the first balanced `{…}` in the reply is
|
|
21
|
+
* parsed (tolerant of prose/fences), `note` must be non-empty (else
|
|
22
|
+
* drop+log), `severity` missing/invalid defaults to `nit`, no parse retry;
|
|
23
|
+
* - the KD-5 failure policy: transient → 1 retry with a short backoff → drop;
|
|
24
|
+
* 3 consecutive dropped deltas → flush the pending backlog (never stall);
|
|
25
|
+
* permanent errors (`invalid_request_error`, model-not-found, "is not
|
|
26
|
+
* supported when") → halt the session's advisor; quota/rate-limit → pause
|
|
27
|
+
* (`quota_exhausted`), batch retained, no auto-resume timer; the in-flight
|
|
28
|
+
* call is aborted on dispose via the `signal`. `halted` is terminal in
|
|
29
|
+
* place — the command layer rebuilds the runtime (dispose + recreate); a
|
|
30
|
+
* `quota_exhausted` runtime resumes via {@link AdvisorRuntime.resume}.
|
|
31
|
+
*
|
|
32
|
+
* The runtime never parks the primary loop: everything is fire-and-forget
|
|
33
|
+
* async and a failing advisor can only drop its own backlog.
|
|
34
|
+
*
|
|
35
|
+
* @module dsh-advisor/advisor-runtime
|
|
36
|
+
*/
|
|
37
|
+
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
38
|
+
if (value !== null && value !== void 0) {
|
|
39
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
40
|
+
var dispose, inner;
|
|
41
|
+
if (async) {
|
|
42
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
43
|
+
dispose = value[Symbol.asyncDispose];
|
|
44
|
+
}
|
|
45
|
+
if (dispose === void 0) {
|
|
46
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
47
|
+
dispose = value[Symbol.dispose];
|
|
48
|
+
if (async) inner = dispose;
|
|
49
|
+
}
|
|
50
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
51
|
+
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
|
|
52
|
+
env.stack.push({ value: value, dispose: dispose, async: async });
|
|
53
|
+
}
|
|
54
|
+
else if (async) {
|
|
55
|
+
env.stack.push({ async: true });
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
};
|
|
59
|
+
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
|
|
60
|
+
return function (env) {
|
|
61
|
+
function fail(e) {
|
|
62
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
63
|
+
env.hasError = true;
|
|
64
|
+
}
|
|
65
|
+
var r, s = 0;
|
|
66
|
+
function next() {
|
|
67
|
+
while (r = env.stack.pop()) {
|
|
68
|
+
try {
|
|
69
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
70
|
+
if (r.dispose) {
|
|
71
|
+
var result = r.dispose.call(r.value);
|
|
72
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
|
|
73
|
+
}
|
|
74
|
+
else s |= 1;
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
fail(e);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
81
|
+
if (env.hasError) throw env.error;
|
|
82
|
+
}
|
|
83
|
+
return next();
|
|
84
|
+
};
|
|
85
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
86
|
+
var e = new Error(message);
|
|
87
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
88
|
+
});
|
|
89
|
+
import { createUserMessage, isQuotaExceededError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
90
|
+
import { INVALID_CREDENTIAL_CODE, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm';
|
|
91
|
+
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout';
|
|
92
|
+
import { createEmissionGuard } from './emission-guard.js';
|
|
93
|
+
/** Pinned policy values (KD-2 / KD-5). */
|
|
94
|
+
const DEFAULT_MAX_TOKENS = 256;
|
|
95
|
+
/**
|
|
96
|
+
* n4 user direction: the advisor call runs with a 20x token budget
|
|
97
|
+
* (256 -> 5120) so even a reasoning-heavy reply cannot starve the JSON frame.
|
|
98
|
+
* Exported so the test suites assert the pinned value instead of a magic
|
|
99
|
+
* literal.
|
|
100
|
+
*
|
|
101
|
+
* Supersession note (qc2 S-2 / qc1 S-2 / qc3 F-2): the frozen spec §8.2
|
|
102
|
+
* (KD-2) pins `maxTokens: 256` ("so a runaway reply cannot blow the budget").
|
|
103
|
+
* This 5120 value is the USER-DIRECTED supersession of that pin — a 20x
|
|
104
|
+
* worst-case per-call ceiling, adopted together with `reasoningEffort: 'off'`
|
|
105
|
+
* (capability-gated, see `resolveReasoningEffort`) so the raised budget goes
|
|
106
|
+
* to the JSON frame rather than reasoning output. The looser runaway-reply
|
|
107
|
+
* guard is re-bounded downstream: `extractAdviceNote` caps the note at
|
|
108
|
+
* `ADVISOR_NOTE_MAX_CHARS` and `buildAdvisorMessage` bounds the notice
|
|
109
|
+
* summary via `boundContextSummary`.
|
|
110
|
+
*/
|
|
111
|
+
export const ADVISOR_MAX_TOKENS = 5_120;
|
|
112
|
+
/**
|
|
113
|
+
* One extracted note's length cap (qc3 F-2 / qc2 S-1): a verbose/rogue advisor
|
|
114
|
+
* reply with the 20x token budget must not inject an unbounded user-role
|
|
115
|
+
* message into the primary session. Truncated with a '…' marker.
|
|
116
|
+
*/
|
|
117
|
+
export const ADVISOR_NOTE_MAX_CHARS = 1_000;
|
|
118
|
+
const DEFAULT_RETRY_BACKOFF_MS = 1_000;
|
|
119
|
+
const DEFAULT_MAX_QUEUED = 32;
|
|
120
|
+
/** Whole-call deadline for one `llm.stream` (qc2 W-4 / qc3 W-1); see `callTimeoutMs`. */
|
|
121
|
+
const DEFAULT_CALL_TIMEOUT_MS = 60_000;
|
|
122
|
+
/** Capability-owned code stamped onto the deadline's TimeoutReason. */
|
|
123
|
+
const ADVISOR_CALL_TIMEOUT = 'ADVISOR_CALL_TIMEOUT';
|
|
124
|
+
/** Transient retries after the first attempt (KD-5: retry(1) → drop). */
|
|
125
|
+
const MAX_TRANSIENT_ATTEMPTS = 1;
|
|
126
|
+
/** Consecutive dropped deltas after which the pending backlog is flushed (KD-5). */
|
|
127
|
+
const MAX_CONSECUTIVE_DROPS = 3;
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// KD-2 — JSON-frame note extraction
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
/**
|
|
132
|
+
* Yield every top-level balanced `{…}` region of `text`, skipping braces
|
|
133
|
+
* inside string literals so a quoted `{`/`}` never corrupts the balance.
|
|
134
|
+
*/
|
|
135
|
+
function* balancedObjects(text) {
|
|
136
|
+
let start = -1;
|
|
137
|
+
let depth = 0;
|
|
138
|
+
let inString = false;
|
|
139
|
+
let escaped = false;
|
|
140
|
+
for (let index = 0; index < text.length; index++) {
|
|
141
|
+
const char = text[index];
|
|
142
|
+
if (inString) {
|
|
143
|
+
if (escaped)
|
|
144
|
+
escaped = false;
|
|
145
|
+
else if (char === '\\')
|
|
146
|
+
escaped = true;
|
|
147
|
+
else if (char === '"')
|
|
148
|
+
inString = false;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (char === '"') {
|
|
152
|
+
inString = true;
|
|
153
|
+
}
|
|
154
|
+
else if (char === '{') {
|
|
155
|
+
if (start < 0) {
|
|
156
|
+
start = index;
|
|
157
|
+
depth = 1;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
depth++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else if (char === '}') {
|
|
164
|
+
if (start >= 0 && --depth === 0) {
|
|
165
|
+
yield text.slice(start, index + 1);
|
|
166
|
+
start = -1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Extract one {@link AdviceNote} from the advisor's reply (KD-2).
|
|
173
|
+
*
|
|
174
|
+
* Locates the first balanced `{…}` object (tolerant of surrounding prose and
|
|
175
|
+
* markdown fences), parses it, and validates: `note` must be a non-empty
|
|
176
|
+
* string after trim (else drop), `severity` missing/invalid defaults to
|
|
177
|
+
* `nit`. A reply with no parseable frame returns `undefined` — the caller
|
|
178
|
+
* drops + logs, and there is NO retry for parse failures (the retry budget is
|
|
179
|
+
* reserved for transport errors, KD-2). The note is capped at
|
|
180
|
+
* {@link ADVISOR_NOTE_MAX_CHARS} with a '…' marker (qc3 F-2 — the 20x token
|
|
181
|
+
* budget must not translate into an unbounded injection into the primary
|
|
182
|
+
* session). Never throws.
|
|
183
|
+
*/
|
|
184
|
+
export function extractAdviceNote(reply) {
|
|
185
|
+
for (const frame of balancedObjects(reply)) {
|
|
186
|
+
let parsed;
|
|
187
|
+
try {
|
|
188
|
+
parsed = JSON.parse(frame);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
continue; // not JSON — try the next balanced region
|
|
192
|
+
}
|
|
193
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
194
|
+
continue;
|
|
195
|
+
const record = parsed;
|
|
196
|
+
const note = record.note;
|
|
197
|
+
if (typeof note !== 'string' || note.trim().length === 0)
|
|
198
|
+
continue; // KD-2: drop empty note
|
|
199
|
+
const severity = record.severity;
|
|
200
|
+
const trimmed = note.trim();
|
|
201
|
+
return {
|
|
202
|
+
note: trimmed.length > ADVISOR_NOTE_MAX_CHARS
|
|
203
|
+
? `${trimmed.slice(0, ADVISOR_NOTE_MAX_CHARS - 1)}…`
|
|
204
|
+
: trimmed,
|
|
205
|
+
severity: severity === 'concern' || severity === 'blocker' ? severity : 'nit',
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* omp's permanent-rejection wording (see the advisor runtime it ports): a
|
|
212
|
+
* request the provider refuses outright for this advisor configuration — no
|
|
213
|
+
* retry can fix it. Halting avoids re-attempting on every new delta forever.
|
|
214
|
+
*/
|
|
215
|
+
const PERMANENT_FAILURE_PATTERN = /invalid_request_error|model[_ ]not[_ ]found|is not supported when|does not exist/i;
|
|
216
|
+
/** Classify one provider failure per KD-5 (quota/rate-limit → pause, permanent → halt). */
|
|
217
|
+
function classifyFailure(failure) {
|
|
218
|
+
if (failure.code === QUOTA_EXCEEDED_CODE
|
|
219
|
+
|| failure.code === 'RATE_LIMIT'
|
|
220
|
+
|| isQuotaExceededError(failure.message)) {
|
|
221
|
+
return 'quota';
|
|
222
|
+
}
|
|
223
|
+
if (failure.code === INVALID_CREDENTIAL_CODE
|
|
224
|
+
|| failure.code === 'NO_ADAPTER'
|
|
225
|
+
|| PERMANENT_FAILURE_PATTERN.test(failure.message)) {
|
|
226
|
+
return 'permanent';
|
|
227
|
+
}
|
|
228
|
+
return 'transient';
|
|
229
|
+
}
|
|
230
|
+
/** Minimal provider-neutral failure snapshot from a thrown value. */
|
|
231
|
+
function normalizeFailure(value) {
|
|
232
|
+
if (value instanceof Error) {
|
|
233
|
+
const code = value.code;
|
|
234
|
+
return {
|
|
235
|
+
message: value.message,
|
|
236
|
+
code: typeof code === 'string' && code.length > 0 ? code : 'UNKNOWN',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return { message: String(value), code: 'UNKNOWN' };
|
|
240
|
+
}
|
|
241
|
+
function sleep(ms) {
|
|
242
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Race one async-iterator demand against a deadline signal: resolve with the
|
|
246
|
+
* iterator's next result, or `'aborted'` when the signal aborts first. A
|
|
247
|
+
* provider error rejects through the race (the caller classifies it). This is
|
|
248
|
+
* what makes a hung stream (no chunk, no end, no error) terminable even when
|
|
249
|
+
* the provider ignores the abort signal — the runtime never depends on the
|
|
250
|
+
* provider honoring `signal` (qc2 W-4 / qc3 W-1).
|
|
251
|
+
*/
|
|
252
|
+
function raceIteratorNext(iterator, signal) {
|
|
253
|
+
if (signal.aborted)
|
|
254
|
+
return Promise.resolve('aborted');
|
|
255
|
+
return new Promise((resolve, reject) => {
|
|
256
|
+
const onAbort = () => resolve('aborted');
|
|
257
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
258
|
+
Promise.resolve()
|
|
259
|
+
.then(() => iterator.next())
|
|
260
|
+
.then((result) => {
|
|
261
|
+
signal.removeEventListener('abort', onAbort);
|
|
262
|
+
resolve(result);
|
|
263
|
+
}, (error) => {
|
|
264
|
+
signal.removeEventListener('abort', onAbort);
|
|
265
|
+
reject(error);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Per-session advisor runtime: queue deltas, async drain, `llm.stream` call,
|
|
271
|
+
* JSON-frame note extraction, and the KD-5 failure policy. All async work is
|
|
272
|
+
* fire-and-forget — the primary loop is never parked.
|
|
273
|
+
*/
|
|
274
|
+
export class AdvisorRuntime {
|
|
275
|
+
provider;
|
|
276
|
+
model;
|
|
277
|
+
systemPrompt;
|
|
278
|
+
maxTokens;
|
|
279
|
+
retryBackoffMs;
|
|
280
|
+
callTimeoutMs;
|
|
281
|
+
maxQueued;
|
|
282
|
+
llm;
|
|
283
|
+
guard;
|
|
284
|
+
onNote;
|
|
285
|
+
logger;
|
|
286
|
+
controller = new AbortController();
|
|
287
|
+
/**
|
|
288
|
+
* Per-(provider, model) reasoning-effort capability cache (qc2 W-1 / qc1
|
|
289
|
+
* W-1 / qc3 F-3): resolving the model's declared efforts on EVERY advisor
|
|
290
|
+
* call would add an adapter round-trip per delta; one resolution per
|
|
291
|
+
* (provider, model) per runtime suffices — the route is pinned for the
|
|
292
|
+
* runtime's lifetime.
|
|
293
|
+
*/
|
|
294
|
+
reasoningEffortCache = new Map();
|
|
295
|
+
queue = [];
|
|
296
|
+
state = 'running';
|
|
297
|
+
draining = false;
|
|
298
|
+
disposed = false;
|
|
299
|
+
consecutiveDrops = 0;
|
|
300
|
+
drainPromise;
|
|
301
|
+
/** Epoch-ms of the last note accepted by the emission guard (T7 status). */
|
|
302
|
+
lastActivityAt;
|
|
303
|
+
constructor(options) {
|
|
304
|
+
this.provider = options.provider;
|
|
305
|
+
this.model = options.model;
|
|
306
|
+
this.systemPrompt = options.systemPrompt;
|
|
307
|
+
this.maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
308
|
+
this.retryBackoffMs = options.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
|
|
309
|
+
this.callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
310
|
+
this.maxQueued = options.maxQueued ?? DEFAULT_MAX_QUEUED;
|
|
311
|
+
this.llm = options.llm;
|
|
312
|
+
this.guard = options.guard ?? createEmissionGuard();
|
|
313
|
+
this.onNote = options.onNote;
|
|
314
|
+
this.logger = options.logger ?? console;
|
|
315
|
+
}
|
|
316
|
+
/** Current per-session status (T7 `/advisor status` surface). */
|
|
317
|
+
status() {
|
|
318
|
+
return this.state;
|
|
319
|
+
}
|
|
320
|
+
/** Number of deltas waiting to be drained (bounded by `maxQueued`). */
|
|
321
|
+
get pendingCount() {
|
|
322
|
+
return this.queue.length;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* T7 `/advisor status` surface — epoch-ms of the last note accepted by the
|
|
326
|
+
* emission guard, or `undefined` before the first accepted note.
|
|
327
|
+
*/
|
|
328
|
+
get lastActivity() {
|
|
329
|
+
return this.lastActivityAt;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Queue one rendered transcript delta (from the T3 observer's `onDelta`).
|
|
333
|
+
* While `quota_exhausted`, deltas queue up (bounded) but the drain is never
|
|
334
|
+
* auto-restarted (KD-5 — no auto-resume timer); while `halted`/disposed they
|
|
335
|
+
* are dropped with a log. Never throws, never parks the caller.
|
|
336
|
+
*/
|
|
337
|
+
enqueue(delta) {
|
|
338
|
+
if (this.disposed) {
|
|
339
|
+
this.logger.debug('advisor: enqueue ignored — runtime disposed');
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (this.state === 'halted') {
|
|
343
|
+
this.logger.debug('advisor: enqueue ignored — advisor halted');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (this.queue.length >= this.maxQueued) {
|
|
347
|
+
// simplify: FIFO queue with drop-newest when full. A full queue means the
|
|
348
|
+
// advisor is far behind; the newest delta is the most redundant. A
|
|
349
|
+
// coalescing batch (omp-style) would be the upgrade path.
|
|
350
|
+
this.logger.debug('advisor: enqueue dropped — backlog full', { maxQueued: this.maxQueued });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
this.queue.push(delta);
|
|
354
|
+
if (this.state === 'quota_exhausted')
|
|
355
|
+
return; // paused: retain, never auto-resume
|
|
356
|
+
this.kickDrain();
|
|
357
|
+
}
|
|
358
|
+
/** Abort the in-flight call and stop the drain (wiring: session/agent disposed). */
|
|
359
|
+
dispose() {
|
|
360
|
+
if (this.disposed)
|
|
361
|
+
return;
|
|
362
|
+
this.disposed = true;
|
|
363
|
+
this.controller.abort('advisor disposed');
|
|
364
|
+
this.queue.length = 0;
|
|
365
|
+
this.consecutiveDrops = 0;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Manual resume after a quota pause (T7 `/advisor on`); no-op when halted/
|
|
369
|
+
* disposed — a halted runtime is terminal in place and is recovered by the
|
|
370
|
+
* command layer via dispose-and-recreate (qc1/qc2/qc3 W-1/I-4), never
|
|
371
|
+
* resumed here.
|
|
372
|
+
*/
|
|
373
|
+
resume() {
|
|
374
|
+
if (this.disposed || this.state === 'halted')
|
|
375
|
+
return;
|
|
376
|
+
this.state = 'running';
|
|
377
|
+
this.kickDrain();
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* KD-5 reset trigger: a compaction / surface rewrite clears the emission
|
|
381
|
+
* guard's dedupe history and per-update latch — the session state is being
|
|
382
|
+
* rewritten, so the old note history no longer applies (a note already
|
|
383
|
+
* advised before the rewrite may legitimately be advised again). The wiring
|
|
384
|
+
* (`index.ts` onRewrite) calls this alongside the delivery cooldown reset.
|
|
385
|
+
*/
|
|
386
|
+
resetGuard() {
|
|
387
|
+
this.guard.reset();
|
|
388
|
+
}
|
|
389
|
+
/** Resolve once the current drain run settles (test/integration hook). */
|
|
390
|
+
async waitForDrain() {
|
|
391
|
+
await this.drainPromise;
|
|
392
|
+
}
|
|
393
|
+
kickDrain() {
|
|
394
|
+
if (this.draining || this.disposed)
|
|
395
|
+
return;
|
|
396
|
+
if (this.state === 'quota_exhausted' || this.state === 'halted')
|
|
397
|
+
return;
|
|
398
|
+
this.drainPromise = this.drain().catch((error) => {
|
|
399
|
+
// Defense in depth: the drain is fire-and-forget, so a rejection here
|
|
400
|
+
// would be an unhandled promise rejection (process crash under Node
|
|
401
|
+
// ≥22/24 defaults). A failing advisor may only drop its own backlog —
|
|
402
|
+
// per-batch errors stay contained inside callModel.
|
|
403
|
+
this.logger.warn('advisor: drain loop failed — contained', { error });
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
/** Serialized drain loop: process the queue one delta at a time. */
|
|
407
|
+
async drain() {
|
|
408
|
+
this.draining = true;
|
|
409
|
+
try {
|
|
410
|
+
while (!this.disposed
|
|
411
|
+
&& this.queue.length > 0
|
|
412
|
+
&& this.state !== 'quota_exhausted'
|
|
413
|
+
&& this.state !== 'halted') {
|
|
414
|
+
const delta = this.queue.shift();
|
|
415
|
+
switch ((await this.processDelta(delta)).kind) {
|
|
416
|
+
case 'note':
|
|
417
|
+
case 'no-note':
|
|
418
|
+
// A completed model call breaks the failure streak — extraction
|
|
419
|
+
// failures (KD-2 drops) are output-quality issues, not transport
|
|
420
|
+
// failures, and do not count toward the 3-drop flush.
|
|
421
|
+
this.consecutiveDrops = 0;
|
|
422
|
+
break;
|
|
423
|
+
case 'drop':
|
|
424
|
+
this.consecutiveDrops++;
|
|
425
|
+
if (this.consecutiveDrops >= MAX_CONSECUTIVE_DROPS)
|
|
426
|
+
this.flushBacklog();
|
|
427
|
+
break;
|
|
428
|
+
case 'requeue':
|
|
429
|
+
this.queue.unshift(delta); // quota pause: batch retained for a later resume
|
|
430
|
+
return;
|
|
431
|
+
case 'halt':
|
|
432
|
+
this.flushBacklog(); // halted: drop any still-pending backlog
|
|
433
|
+
return;
|
|
434
|
+
case 'aborted':
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
this.draining = false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/** KD-5: clear the pending backlog after consecutive failures (never stall). */
|
|
444
|
+
flushBacklog() {
|
|
445
|
+
this.consecutiveDrops = 0;
|
|
446
|
+
if (this.queue.length === 0)
|
|
447
|
+
return;
|
|
448
|
+
const flushed = this.queue.length;
|
|
449
|
+
this.queue.length = 0;
|
|
450
|
+
this.logger.warn('advisor: flushed pending backlog after consecutive failures', { flushed });
|
|
451
|
+
}
|
|
452
|
+
/** Process one delta: attempt + single retry (transient), classify terminal failures. */
|
|
453
|
+
async processDelta(delta) {
|
|
454
|
+
// One advisor model cycle per delta: the emission guard's one-note-per-
|
|
455
|
+
// update latch resets here, so each processed delta may deliver one note
|
|
456
|
+
// again (spec §6; the reset point is the drain's per-delta boundary).
|
|
457
|
+
this.guard.beginUpdate();
|
|
458
|
+
for (let attempt = 0; attempt <= MAX_TRANSIENT_ATTEMPTS; attempt++) {
|
|
459
|
+
if (attempt > 0) {
|
|
460
|
+
if (this.disposed)
|
|
461
|
+
return { kind: 'aborted' };
|
|
462
|
+
await sleep(this.retryBackoffMs);
|
|
463
|
+
if (this.disposed)
|
|
464
|
+
return { kind: 'aborted' };
|
|
465
|
+
}
|
|
466
|
+
const result = await this.callModel(delta);
|
|
467
|
+
if (result.kind === 'note' || result.kind === 'no-note' || result.kind === 'aborted')
|
|
468
|
+
return result;
|
|
469
|
+
switch (classifyFailure(result.failure)) {
|
|
470
|
+
case 'quota':
|
|
471
|
+
this.state = 'quota_exhausted';
|
|
472
|
+
this.logger.warn('advisor: quota/rate-limit reached — paused, batch retained', { failure: result.failure });
|
|
473
|
+
return { kind: 'requeue' };
|
|
474
|
+
case 'permanent':
|
|
475
|
+
this.state = 'halted';
|
|
476
|
+
this.logger.warn('advisor: permanent model error — halted', { failure: result.failure });
|
|
477
|
+
return { kind: 'halt' };
|
|
478
|
+
case 'transient':
|
|
479
|
+
break; // retry once (the loop's next iteration)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
this.logger.warn('advisor: dropping delta after transient failures', { attempts: MAX_TRANSIENT_ATTEMPTS + 1 });
|
|
483
|
+
return { kind: 'drop' };
|
|
484
|
+
}
|
|
485
|
+
/** One model call: build options, stream text, extract the note (KD-2). */
|
|
486
|
+
async callModel(delta) {
|
|
487
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
488
|
+
try {
|
|
489
|
+
let text = '';
|
|
490
|
+
let finish;
|
|
491
|
+
// Call-level deadline (dsh-timeout): fuses the runtime's dispose signal
|
|
492
|
+
// with a whole-call timer. The fused signal is passed to the provider
|
|
493
|
+
// (AbortSignal honored) AND raced per chunk below, so a hung stream cannot
|
|
494
|
+
// wedge the drain even when the provider ignores the signal. A timeout is
|
|
495
|
+
// a transient failure — KD-5 retry(1) → drop (qc2 W-4 / qc3 W-1).
|
|
496
|
+
const deadlineHandle = __addDisposableResource(env_1, deadline(this.controller.signal, this.callTimeoutMs, ADVISOR_CALL_TIMEOUT), false);
|
|
497
|
+
const deadlineSignal = deadlineHandle.signal;
|
|
498
|
+
try {
|
|
499
|
+
// Capability-gate `reasoningEffort` (qc2 W-1 / qc1 W-1 / qc3 F-3):
|
|
500
|
+
// resolve the model's declared efforts ONCE per (provider, model) and
|
|
501
|
+
// pass 'off' only when supported; never fail the call on a resolution
|
|
502
|
+
// error (the option is simply omitted). The resolution is bounded by the
|
|
503
|
+
// call deadline (n4 QC N-5): the fused signal is threaded through, so a
|
|
504
|
+
// hung adapter capability lookup that honors cancellation aborts with
|
|
505
|
+
// the call instead of wedging the drain ahead of the deadline-guarded
|
|
506
|
+
// stream loop.
|
|
507
|
+
const reasoningEffort = await this.resolveReasoningEffort(deadlineSignal);
|
|
508
|
+
const stream = this.llm.stream(this.buildOptions(delta, deadlineSignal, reasoningEffort));
|
|
509
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
510
|
+
for (;;) {
|
|
511
|
+
const next = await raceIteratorNext(iterator, deadlineSignal);
|
|
512
|
+
if (next === 'aborted') {
|
|
513
|
+
// Best-effort teardown of a provider that may still be mid-flight;
|
|
514
|
+
// never await a hung teardown (it may never settle).
|
|
515
|
+
iterator.return?.().catch(() => { });
|
|
516
|
+
if (timeoutOf(deadlineSignal, ADVISOR_CALL_TIMEOUT) !== undefined) {
|
|
517
|
+
return {
|
|
518
|
+
kind: 'failure',
|
|
519
|
+
failure: { message: `advisor call timed out after ${this.callTimeoutMs}ms`, code: 'TIMEOUT' },
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
return { kind: 'aborted' }; // dispose aborted the in-flight call
|
|
523
|
+
}
|
|
524
|
+
if (next.done)
|
|
525
|
+
break;
|
|
526
|
+
const chunk = next.value;
|
|
527
|
+
if (chunk.type === 'text-delta')
|
|
528
|
+
text += chunk.text;
|
|
529
|
+
else if (chunk.type === 'finish')
|
|
530
|
+
finish = chunk.reason;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
return { kind: 'failure', failure: normalizeFailure(error) };
|
|
535
|
+
}
|
|
536
|
+
if (finish === undefined) {
|
|
537
|
+
return { kind: 'failure', failure: { message: 'advisor stream ended without a finish chunk', code: 'UNKNOWN' } };
|
|
538
|
+
}
|
|
539
|
+
if (finish.kind === 'error')
|
|
540
|
+
return { kind: 'failure', failure: finish.failure };
|
|
541
|
+
if (finish.kind === 'aborted') {
|
|
542
|
+
// Our own dispose aborts the in-flight call; a provider-side abort is a
|
|
543
|
+
// terminal failure like any other. A deadline timeout surfaced as a
|
|
544
|
+
// provider abort (rather than through the per-chunk race) is still the
|
|
545
|
+
// transient timeout case — KD-5 retry once → drop.
|
|
546
|
+
if (timeoutOf(deadlineSignal, ADVISOR_CALL_TIMEOUT) !== undefined) {
|
|
547
|
+
return {
|
|
548
|
+
kind: 'failure',
|
|
549
|
+
failure: { message: `advisor call timed out after ${this.callTimeoutMs}ms`, code: 'TIMEOUT' },
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
if (this.disposed || this.controller.signal.aborted)
|
|
553
|
+
return { kind: 'aborted' };
|
|
554
|
+
return { kind: 'failure', failure: finish.failure };
|
|
555
|
+
}
|
|
556
|
+
// stop | max-tokens | tool-calls: extract from the collected text (KD-2;
|
|
557
|
+
// no retry on parse failures — the frame must simply be absent/valid).
|
|
558
|
+
const note = extractAdviceNote(text);
|
|
559
|
+
if (note === undefined) {
|
|
560
|
+
this.logger.debug('advisor: reply yielded no note — dropped (KD-2)');
|
|
561
|
+
return { kind: 'no-note' };
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
// The T5 emission guard sits between extraction and delivery: only
|
|
565
|
+
// accepted notes reach the delivery callback (T6). Suppression is
|
|
566
|
+
// silent — the caller cannot tell an accepted from a suppressed note,
|
|
567
|
+
// and a guard failure must never crash the drain (T4 F1 containment).
|
|
568
|
+
if (this.guard.accept(note)) {
|
|
569
|
+
// T7: timestamp the moment a note is accepted for delivery (before
|
|
570
|
+
// onNote, so a throwing delivery seam cannot lose the activity
|
|
571
|
+
// record) — surfaced by `/advisor status` as "last activity".
|
|
572
|
+
this.lastActivityAt = Date.now();
|
|
573
|
+
this.onNote(note);
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
this.logger.debug('advisor: note suppressed by emission guard', {
|
|
577
|
+
note: note.note,
|
|
578
|
+
severity: note.severity,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
// The emission guard / delivery seam must never crash the drain: log
|
|
584
|
+
// and continue. The model call itself succeeded — this is not a
|
|
585
|
+
// transport failure, so retry/drop/quota/halt semantics are untouched
|
|
586
|
+
// and the note still counts as extracted.
|
|
587
|
+
this.logger.warn('advisor: emission guard or delivery callback threw — contained', { error });
|
|
588
|
+
}
|
|
589
|
+
return { kind: 'note', note };
|
|
590
|
+
}
|
|
591
|
+
catch (e_1) {
|
|
592
|
+
env_1.error = e_1;
|
|
593
|
+
env_1.hasError = true;
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
__disposeResources(env_1);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
buildOptions(delta, signal, reasoningEffort) {
|
|
600
|
+
return {
|
|
601
|
+
provider: this.provider,
|
|
602
|
+
model: this.model,
|
|
603
|
+
system: this.systemPrompt,
|
|
604
|
+
messages: [createUserMessage({
|
|
605
|
+
content: [{ type: 'text', text: delta.markdown }],
|
|
606
|
+
source: { kind: 'user' },
|
|
607
|
+
})],
|
|
608
|
+
// n4 root-cause (host-observed "reply yielded no note"): deepseek-v4-flash
|
|
609
|
+
// is a reasoning model — with the default 256-token budget the reasoning
|
|
610
|
+
// stream consumed the whole budget, the text output came back EMPTY
|
|
611
|
+
// (finish: max-tokens), and KD-2 dropped every reply. Turn reasoning OFF
|
|
612
|
+
// so the budget goes to the JSON frame, and raise it for headroom
|
|
613
|
+
// (ADVISOR_MAX_TOKENS). The advisor's job is a short structured note —
|
|
614
|
+
// reasoning is not needed. Capability-gated (qc2 W-1 / qc1 W-1 / qc3
|
|
615
|
+
// F-3): `resolveReasoningEffort` passes the branded 'off' ONLY when the
|
|
616
|
+
// resolved model declares it; otherwise the option is omitted entirely
|
|
617
|
+
// (the dsh LlmRuntime would reject an explicit effort for a model whose
|
|
618
|
+
// adapter lacks reasoning metadata with UNSUPPORTED_REASONING_EFFORT,
|
|
619
|
+
// silently killing the advisor for non-deepseek models — pre-n4 these
|
|
620
|
+
// worked because no effort was sent).
|
|
621
|
+
...(reasoningEffort === undefined ? {} : { reasoningEffort }),
|
|
622
|
+
// n4 user direction: amplify the token budget 20x (256 -> 5120) so even a
|
|
623
|
+
// reasoning-heavy reply cannot starve the JSON frame.
|
|
624
|
+
maxTokens: ADVISOR_MAX_TOKENS,
|
|
625
|
+
signal,
|
|
626
|
+
// KD-5: `purpose` is a closed union ('compaction' | 'session-title'); an
|
|
627
|
+
// advisor call is an ordinary conversation request and leaves it unset.
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Capability-gate the `reasoningEffort: 'off'` option (qc2 W-1 / qc1 W-1 /
|
|
632
|
+
* qc3 F-3): pass the branded 'off' effort only when the resolved model's
|
|
633
|
+
* `reasoning.efforts` includes it, else omit the option (`resolveCallFor`
|
|
634
|
+
* materializes the adapter default). Cached per (provider, model) — one
|
|
635
|
+
* resolution per runtime, never per call. A resolution failure (unknown
|
|
636
|
+
* route, adapter throw, or a deadline abort — n4 QC N-5) is advisory: the
|
|
637
|
+
* call proceeds WITHOUT the option, matching the pre-n4 behavior for every
|
|
638
|
+
* model that does not declare 'off'. The optional `signal` (the call's
|
|
639
|
+
* deadline signal) is threaded into `resolveModelInfo`, whose contract
|
|
640
|
+
* allows adapter-owned asynchronous lookup with cancellation — a hung
|
|
641
|
+
* lookup that honors the signal aborts with the call deadline instead of
|
|
642
|
+
* wedging the drain.
|
|
643
|
+
*/
|
|
644
|
+
async resolveReasoningEffort(signal) {
|
|
645
|
+
const key = `${this.provider}\u0000${this.model}`;
|
|
646
|
+
if (this.reasoningEffortCache.has(key))
|
|
647
|
+
return this.reasoningEffortCache.get(key);
|
|
648
|
+
let effort;
|
|
649
|
+
try {
|
|
650
|
+
const info = await this.llm.resolveModelInfo?.(this.provider, this.model, signal);
|
|
651
|
+
effort = info?.reasoning?.efforts.some((entry) => entry.id === ReasoningEffortId('off'))
|
|
652
|
+
? ReasoningEffortId('off')
|
|
653
|
+
: undefined;
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
effort = undefined;
|
|
657
|
+
}
|
|
658
|
+
this.reasoningEffortCache.set(key, effort);
|
|
659
|
+
return effort;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
//# sourceMappingURL=advisor-runtime.js.map
|