dsh-switchman 1.7.0 → 1.8.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/README.de.md +25 -4
- package/README.es.md +25 -4
- package/README.fr.md +25 -4
- package/README.it.md +25 -4
- package/README.ja.md +25 -4
- package/README.ko.md +25 -4
- package/README.md +26 -4
- package/README.pt.md +25 -4
- package/README.ru.md +25 -4
- package/README.zh-TW.md +25 -4
- package/README.zh.md +26 -4
- package/client.js +1916 -35
- package/host/config.js +93 -4
- package/host/context-watch.js +126 -58
- package/host/intent-presets.js +163 -0
- package/host/intent.js +952 -0
- package/host/routes.js +158 -3
- package/host/vision.js +62 -26
- package/index.js +18 -2
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +3 -3
package/host/intent.js
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
/** dsh-switchman intent-routing layer: the "auto" pseudo-model.
|
|
2
|
+
*
|
|
3
|
+
* Adds a `dsh-switchman-auto/auto` entry to the native model catalog (via
|
|
4
|
+
* `ctx.llm.registerAdapter`) so the composer dropdown offers an "Auto" route,
|
|
5
|
+
* and routes that pseudo route onto a real model per request: the adapter's
|
|
6
|
+
* own stream is THE routing point — it classifies the recent conversation
|
|
7
|
+
* against the configured candidate list with a small classifier model,
|
|
8
|
+
* advances the per-session state machine, and forwards to the decided route.
|
|
9
|
+
* (The host's agent/request waterfall cannot do this: it throws when a
|
|
10
|
+
* listener proposes provider/model different from the selection fixed at
|
|
11
|
+
* step preparation, and its result is structuredClone'd — spreading this
|
|
12
|
+
* plugin's volatile config into the return value crashed requests with
|
|
13
|
+
* "() => current could not be cloned".) The switch is silent (no
|
|
14
|
+
* `[model changed]` notice, no cache-breaking session mutation); retries
|
|
15
|
+
* re-dispatching the same options object pin to the decided route via a
|
|
16
|
+
* WeakMap; every model call within one user message reuses that message's
|
|
17
|
+
* decision — the turn pin keys on the newest user/message event id, because
|
|
18
|
+
* GenerateOptions carries no turn number (the abandoned waterfall did);
|
|
19
|
+
* a provider prompt-cache window keeps the current model while the
|
|
20
|
+
* classifier's confidence stays below the switch threshold (design:
|
|
21
|
+
* .dsh-switchman/plans/intent-routing.md — field names/protocol/state machine
|
|
22
|
+
* are frozen there).
|
|
23
|
+
*
|
|
24
|
+
* Fail-open everywhere: a missing service, an unconfigured classifier, a
|
|
25
|
+
* timeout/abort, or an unparseable verdict degrades to the fallback chain
|
|
26
|
+
* (pinned → previous routed route → intentFallbackRoute → first candidate);
|
|
27
|
+
* with nothing routable at all the stream throws an explicit error telling
|
|
28
|
+
* the user to pick a concrete model. All services are reached
|
|
29
|
+
* through defensive `ctx.get` reads — a deployment without the `llm` service
|
|
30
|
+
* leaves the layer a warn-once no-op (documented runtime precondition, same
|
|
31
|
+
* as index.js's inject note for sessionQuery/pluginManager). The classifier's
|
|
32
|
+
* own call is a session-title-llm-style auxiliary request (real provider ≠
|
|
33
|
+
* AUTO_PROVIDER → cannot re-enter this adapter), with
|
|
34
|
+
* a local AbortController+setTimeout deadline instead of the
|
|
35
|
+
* @deepseek-ai/dsh-timeout dependency (this bundle adds no dependencies).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { randomUUID } from "node:crypto";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
import { effectiveIntentCandidates, mergeIntentCandidates } from "./intent-presets.js";
|
|
41
|
+
import { sessionModelSelectionOf } from "./subagent-model.js";
|
|
42
|
+
|
|
43
|
+
/** Pseudo provider id (must match client.js and the design contract). */
|
|
44
|
+
export const AUTO_PROVIDER = "dsh-switchman-auto";
|
|
45
|
+
|
|
46
|
+
/** Pseudo model id under AUTO_PROVIDER. */
|
|
47
|
+
export const AUTO_MODEL = "auto";
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Volatile settings readers (mirror host/dispatch.js's guards — these files
|
|
52
|
+
// stay independently mountable).
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/** Read one volatile settings field as a plain string. */
|
|
56
|
+
function readField(field) {
|
|
57
|
+
if (field !== null && typeof field === "object" && typeof field.get === "function") {
|
|
58
|
+
try {
|
|
59
|
+
const value = field.get();
|
|
60
|
+
return typeof value === "string" ? value : "";
|
|
61
|
+
} catch {
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return typeof field === "string" ? field : "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Boolean volatile-field read (false on any malformed value). */
|
|
69
|
+
function readBool(field) {
|
|
70
|
+
if (field !== null && typeof field === "object" && typeof field.get === "function") {
|
|
71
|
+
try {
|
|
72
|
+
return field.get() === true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return field === true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Numeric volatile-field read (fallback on any malformed value). */
|
|
81
|
+
function readNumber(field, fallback) {
|
|
82
|
+
if (field !== null && typeof field === "object" && typeof field.get === "function") {
|
|
83
|
+
try {
|
|
84
|
+
const value = Number(field.get());
|
|
85
|
+
return Number.isFinite(value) ? value : fallback;
|
|
86
|
+
} catch {
|
|
87
|
+
return fallback;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const value = Number(field);
|
|
91
|
+
return Number.isFinite(value) ? value : fallback;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Array volatile-field read (non-arrays degrade to []). */
|
|
95
|
+
function readList(field) {
|
|
96
|
+
if (field !== null && typeof field === "object" && typeof field.get === "function") {
|
|
97
|
+
try {
|
|
98
|
+
const value = field.get();
|
|
99
|
+
return Array.isArray(value) ? value : [];
|
|
100
|
+
} catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return Array.isArray(field) ? field : [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One validated intent route reference ({provider, model} both non-empty
|
|
108
|
+
* after trim, plus optional non-empty effort), or null when unset. */
|
|
109
|
+
function readRoute(field) {
|
|
110
|
+
if (field !== null && typeof field === "object" && typeof field.get === "function") {
|
|
111
|
+
try {
|
|
112
|
+
return routeOf(field.get());
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return routeOf(field);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Validate one raw route-like value; null unless provider+model are
|
|
121
|
+
* non-empty strings. A non-empty effort rides along as a pin. */
|
|
122
|
+
function routeOf(value) {
|
|
123
|
+
if (value === null || typeof value !== "object") return null;
|
|
124
|
+
const provider = typeof value.provider === "string" ? value.provider.trim() : "";
|
|
125
|
+
const model = typeof value.model === "string" ? value.model.trim() : "";
|
|
126
|
+
if (provider === "" || model === "") return null;
|
|
127
|
+
// Never route TO the pseudo provider itself: the rewrite would hand the
|
|
128
|
+
// pseudo model back to the loop and the adapter fallback would re-enter
|
|
129
|
+
// itself forever (review finding, 2026-10-24).
|
|
130
|
+
if (provider === AUTO_PROVIDER) return null;
|
|
131
|
+
const route = { provider, model };
|
|
132
|
+
if (typeof value.effort === "string" && value.effort.trim() !== "") route.effort = value.effort.trim();
|
|
133
|
+
return route;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Effective context window (tokens) the Auto pseudo-model should declare.
|
|
137
|
+
* The point of a custom Auto window is to position DSH core's
|
|
138
|
+
* 0.8×-window auto-compaction line AFTER switchman's absolute force-tier
|
|
139
|
+
* handover, so the smooth four-step handover always fires before the
|
|
140
|
+
* cruder single-shot compaction. The 0.8 ratio is compaction-basic's
|
|
141
|
+
* default thresholdRatio (documented assumption, docs/porting-plan.md).
|
|
142
|
+
* While wmAutoHandover is on, the declared value is floored at
|
|
143
|
+
* ceil(wmForceTokens / 0.8) to preserve that invariant; with automatic
|
|
144
|
+
* handover off the user's raw window is respected as-is — they opted out
|
|
145
|
+
* of the handover pipeline, so the 0.8 line may sit wherever they put it. */
|
|
146
|
+
export function effectiveContextWindow(config) {
|
|
147
|
+
const user = Math.max(1024, Math.round(readNumber(config.intentContextWindow, 200000)));
|
|
148
|
+
if (!readBool(config.wmAutoHandover)) return user;
|
|
149
|
+
const floor = Math.ceil(readNumber(config.wmForceTokens, 150000) / 0.8);
|
|
150
|
+
return Math.max(user, floor);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Validated intent-candidate list: routes plus their optional effort/note. */
|
|
154
|
+
export function readCandidates(field) {
|
|
155
|
+
// Unified list = stored rows merged with the built-in presets; only
|
|
156
|
+
// enabled rows with a complete route reach the classifier, so a preset
|
|
157
|
+
// still waiting for its model (or a freshly added custom row) never
|
|
158
|
+
// dilutes the prompt.
|
|
159
|
+
const candidates = [];
|
|
160
|
+
const byRoute = new Map();
|
|
161
|
+
for (const entry of effectiveIntentCandidates(mergeIntentCandidates(readList(field)))) {
|
|
162
|
+
if (entry.provider === AUTO_PROVIDER) continue; // self-reference guard, mirrors routeOf
|
|
163
|
+
// Collapse same-route rows (several presets mapped to one model) into
|
|
164
|
+
// ONE classifier candidate. The merged note carries the route's FULL
|
|
165
|
+
// job description — keep-first would silently ignore the user's
|
|
166
|
+
// mapping for every non-first category on that route.
|
|
167
|
+
const routeKey = `${entry.provider}/${entry.model}`;
|
|
168
|
+
let candidate = byRoute.get(routeKey);
|
|
169
|
+
if (candidate === undefined) {
|
|
170
|
+
candidate = { provider: entry.provider, model: entry.model, noteSet: new Set() };
|
|
171
|
+
byRoute.set(routeKey, candidate);
|
|
172
|
+
candidates.push(candidate);
|
|
173
|
+
}
|
|
174
|
+
const note = typeof entry.note === "string" ? entry.note.trim() : "";
|
|
175
|
+
if (note !== "") candidate.noteSet.add(note);
|
|
176
|
+
const effort = typeof entry.effort === "string" ? entry.effort.trim() : "";
|
|
177
|
+
if (effort !== "" && candidate.effort === undefined) candidate.effort = effort;
|
|
178
|
+
}
|
|
179
|
+
for (const candidate of candidates) {
|
|
180
|
+
const notes = [...candidate.noteSet];
|
|
181
|
+
if (notes.length > 0) candidate.note = notes.join(" ");
|
|
182
|
+
delete candidate.noteSet;
|
|
183
|
+
}
|
|
184
|
+
return candidates;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Pure helpers (exported for tests).
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** Stable identity of one provider/model route (effort is a pin, not part
|
|
192
|
+
* of the model identity). */
|
|
193
|
+
function routeKeyOf(route) {
|
|
194
|
+
return `${route?.provider ?? ""}\u0000${route?.model ?? ""}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Copy one route with its effort pin kept only when a non-empty string. */
|
|
198
|
+
function withEffort(route) {
|
|
199
|
+
if (route === null || typeof route !== "object") return null;
|
|
200
|
+
const copy = { provider: route.provider, model: route.model };
|
|
201
|
+
if (typeof route.effort === "string" && route.effort !== "") copy.effort = route.effort;
|
|
202
|
+
return copy;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** First JSON number found in a number-or-numeric-string value. */
|
|
206
|
+
function numberOr(value) {
|
|
207
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
208
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value.trim()))) return Number(value.trim());
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Validate one parsed verdict object against the candidate count: an
|
|
213
|
+
* in-range integer `best` (0-based) with a clamped 0-100 confidence
|
|
214
|
+
* (missing confidence = decisive pick, 100); null otherwise. */
|
|
215
|
+
function verdictFromValue(value, candidateCount) {
|
|
216
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
217
|
+
const best = numberOr(value.best);
|
|
218
|
+
if (best === null || !Number.isInteger(best) || best < 0 || best >= candidateCount) return null;
|
|
219
|
+
const confidence = numberOr(value.confidence);
|
|
220
|
+
return {
|
|
221
|
+
best,
|
|
222
|
+
confidence: confidence === null ? 100 : Math.min(100, Math.max(0, Math.round(confidence))),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** First balanced `{...}` segment of a text (string/escape aware), or null. */
|
|
227
|
+
function firstBalancedObject(text) {
|
|
228
|
+
const start = text.indexOf("{");
|
|
229
|
+
if (start < 0) return null;
|
|
230
|
+
let depth = 0;
|
|
231
|
+
let inString = false;
|
|
232
|
+
let escaped = false;
|
|
233
|
+
for (let i = start; i < text.length; i++) {
|
|
234
|
+
const ch = text[i];
|
|
235
|
+
if (inString) {
|
|
236
|
+
if (escaped) escaped = false;
|
|
237
|
+
else if (ch === "\\") escaped = true;
|
|
238
|
+
else if (ch === '"') inString = false;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (ch === '"') inString = true;
|
|
242
|
+
else if (ch === "{") depth++;
|
|
243
|
+
else if (ch === "}") {
|
|
244
|
+
depth--;
|
|
245
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Tolerant classifier-verdict parser (design §3 chain, exported for tests):
|
|
253
|
+
* ① direct JSON.parse → ② first balanced {...} segment → ③ JSON inside a
|
|
254
|
+
* markdown code fence → ④ first bare-integer line (confidence = 100).
|
|
255
|
+
* @param {string} text - raw classifier output.
|
|
256
|
+
* @param {number} candidateCount - number of candidates (best is 0-based).
|
|
257
|
+
* @returns {{best: number, confidence: number} | null} null when nothing
|
|
258
|
+
* parseable yields an in-range best (callers fail open).
|
|
259
|
+
*/
|
|
260
|
+
export function parseClassifierVerdict(text, candidateCount) {
|
|
261
|
+
if (typeof text !== "string" || !Number.isInteger(candidateCount) || candidateCount < 1) return null;
|
|
262
|
+
try {
|
|
263
|
+
const direct = verdictFromValue(JSON.parse(text), candidateCount);
|
|
264
|
+
if (direct !== null) return direct;
|
|
265
|
+
} catch {
|
|
266
|
+
/* not bare JSON — keep the chain */
|
|
267
|
+
}
|
|
268
|
+
const segment = firstBalancedObject(text);
|
|
269
|
+
if (segment !== null) {
|
|
270
|
+
try {
|
|
271
|
+
const inner = verdictFromValue(JSON.parse(segment), candidateCount);
|
|
272
|
+
if (inner !== null) return inner;
|
|
273
|
+
} catch {
|
|
274
|
+
/* malformed segment — keep the chain */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const fence = /```[a-zA-Z]*\r?\n?([\s\S]*?)```/.exec(text);
|
|
278
|
+
if (fence !== null) {
|
|
279
|
+
const body = fence[1].trim();
|
|
280
|
+
try {
|
|
281
|
+
const fenced = verdictFromValue(JSON.parse(body), candidateCount);
|
|
282
|
+
if (fenced !== null) return fenced;
|
|
283
|
+
} catch {
|
|
284
|
+
/* fence without bare JSON — try a balanced segment inside it */
|
|
285
|
+
}
|
|
286
|
+
const innerSegment = firstBalancedObject(body);
|
|
287
|
+
if (innerSegment !== null) {
|
|
288
|
+
try {
|
|
289
|
+
const inner = verdictFromValue(JSON.parse(innerSegment), candidateCount);
|
|
290
|
+
if (inner !== null) return inner;
|
|
291
|
+
} catch {
|
|
292
|
+
/* keep the chain */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const line = /^[ \t]*(\d+)[ \t]*$/m.exec(text);
|
|
297
|
+
if (line !== null) {
|
|
298
|
+
const best = Number(line[1]);
|
|
299
|
+
if (Number.isInteger(best) && best >= 0 && best < candidateCount) return { best, confidence: 100 };
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The routing decision state machine (design §4, exported for tests). Pure:
|
|
306
|
+
* no clocks, no settings reads, no mutation.
|
|
307
|
+
* @param {object} args
|
|
308
|
+
* @param {Array<{provider: string, model: string, effort?: string}>} args.candidates
|
|
309
|
+
* @param {{provider: string, model: string, effort?: string} | null} args.current
|
|
310
|
+
* previously routed route (cache-reuse anchor).
|
|
311
|
+
* @param {{best: number, confidence: number} | null} args.parsed
|
|
312
|
+
* classifier verdict, or null (parse failure/timeout/disabled → chain).
|
|
313
|
+
* @param {number} args.now - decision timestamp (ms).
|
|
314
|
+
* @param {number} args.routedAt - when `current` was last applied (ms), or
|
|
315
|
+
* null when unknown (the task brief's signature omits it; §4's cache rule
|
|
316
|
+
* needs the previous-route timestamp, so it rides along here).
|
|
317
|
+
* @param {number} args.cacheTtlSeconds - provider prompt-cache window.
|
|
318
|
+
* @param {number} args.switchConfidence - threshold to leave the current model.
|
|
319
|
+
* @param {{provider: string, model: string, effort?: string} | null} args.fallback
|
|
320
|
+
* @returns {{route: {provider: string, model: string, effort?: string} | null,
|
|
321
|
+
* switched: boolean, reason: string}} route null = nothing routable (the
|
|
322
|
+
* pseudo model passes through; the adapter explains how to configure).
|
|
323
|
+
*/
|
|
324
|
+
export function decideRoute({ candidates, current, parsed, now, routedAt, cacheTtlSeconds, switchConfidence, fallback }) {
|
|
325
|
+
const list = Array.isArray(candidates) ? candidates.filter((entry) => routeOf(entry) !== null) : [];
|
|
326
|
+
const ttlMs = Math.max(0, Number(cacheTtlSeconds) || 0) * 1000;
|
|
327
|
+
const threshold = Number.isFinite(Number(switchConfidence)) ? Number(switchConfidence) : 80;
|
|
328
|
+
const chain = () => {
|
|
329
|
+
if (routeOf(current) !== null) return { route: withEffort(current), switched: false, reason: "fallback-keep-current" };
|
|
330
|
+
if (routeOf(fallback) !== null) return { route: withEffort(fallback), switched: false, reason: "fallback-configured" };
|
|
331
|
+
if (list.length > 0) return { route: withEffort(list[0]), switched: true, reason: "fallback-first-candidate" };
|
|
332
|
+
return { route: null, switched: false, reason: "unavailable" };
|
|
333
|
+
};
|
|
334
|
+
if (parsed === null || typeof parsed !== "object") return chain();
|
|
335
|
+
const bestRoute = routeOf(list[parsed.best]) === null ? null : withEffort(list[parsed.best]);
|
|
336
|
+
if (bestRoute === null) return chain(); // defensive: verdict outlived its candidate list
|
|
337
|
+
const currentRoute = routeOf(current) === null ? null : withEffort(current);
|
|
338
|
+
if (currentRoute !== null) {
|
|
339
|
+
if (routeKeyOf(currentRoute) === routeKeyOf(bestRoute)) {
|
|
340
|
+
return { route: currentRoute, switched: false, reason: "keep-best-is-current" };
|
|
341
|
+
}
|
|
342
|
+
const currentListed = list.some((entry) => routeKeyOf(entry) === routeKeyOf(currentRoute));
|
|
343
|
+
if (!currentListed) return { route: bestRoute, switched: true, reason: "switch-current-not-candidate" };
|
|
344
|
+
if (parsed.confidence >= threshold) return { route: bestRoute, switched: true, reason: "switch-confidence" };
|
|
345
|
+
const withinWindow = Number.isFinite(Number(routedAt)) && Number(now) - Number(routedAt) < ttlMs;
|
|
346
|
+
if (withinWindow) return { route: currentRoute, switched: false, reason: "keep-cache-window" };
|
|
347
|
+
return { route: bestRoute, switched: true, reason: "switch-cache-expired" };
|
|
348
|
+
}
|
|
349
|
+
// No previously routed model (first decision): the best pick applies.
|
|
350
|
+
return { route: bestRoute, switched: true, reason: "switch-first-decision" };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Per-session decision state (host/handover-state.js pattern: module-level,
|
|
355
|
+
// process-lifetime, never persisted; cleaned on session/disposed).
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
/** sessionId -> { pinnedTurnKey, decision, routedCurrent, routedAt,
|
|
359
|
+
* lastDecision } (design §4). */
|
|
360
|
+
const sessionState = new Map();
|
|
361
|
+
|
|
362
|
+
function stateOf(sessionId) {
|
|
363
|
+
let state = sessionState.get(sessionId);
|
|
364
|
+
if (state === undefined) {
|
|
365
|
+
state = { pinnedTurnKey: null, decision: null, routedCurrent: null, routedAt: null, lastDecision: null };
|
|
366
|
+
sessionState.set(sessionId, state);
|
|
367
|
+
}
|
|
368
|
+
return state;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Drop one session's decision state (session/disposed + intent-reset route). */
|
|
372
|
+
export function clearSessionState(sessionId) {
|
|
373
|
+
if (typeof sessionId === "string" && sessionId !== "") sessionState.delete(sessionId);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// Classifier prompt + conversation excerpt.
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
/** System prompt for the chat-channel classifier (design §3: strict JSON;
|
|
381
|
+
* tolerant parsing still accepts legacy bare-integer replies). */
|
|
382
|
+
function classifierSystemPrompt() {
|
|
383
|
+
return 'You route this conversation to one candidate model. Reply with STRICT JSON only: {"best": <candidate number>, "confidence": <0-100>}. No prose, no markdown fence.';
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Candidate list lines: `#N provider/model — note`, the currently routed
|
|
387
|
+
* model annotated. */
|
|
388
|
+
function candidateLines(candidates, currentKey) {
|
|
389
|
+
return candidates.map((candidate, index) => {
|
|
390
|
+
const current = routeKeyOf(candidate) === currentKey ? " (current)" : "";
|
|
391
|
+
const note = typeof candidate.note === "string" && candidate.note !== "" ? ` — ${candidate.note}` : "";
|
|
392
|
+
return `#${index} ${candidate.provider}/${candidate.model}${current}${note}`;
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The user-turn text: candidates, the neutral tie-break hint, the recent
|
|
397
|
+
* conversation excerpt, and the strict-JSON question. */
|
|
398
|
+
function classifierInput(config, candidates, current, excerpt) {
|
|
399
|
+
const currentKey = current === null ? "\u0000-none" : routeKeyOf(current);
|
|
400
|
+
const question =
|
|
401
|
+
'Which candidate number is best for the next reply? Reply with strict JSON {"best": <number>, "confidence": <0-100>}.';
|
|
402
|
+
return [
|
|
403
|
+
"Candidates:",
|
|
404
|
+
...candidateLines(candidates, currentKey),
|
|
405
|
+
"When no candidate has a clear advantage, prefer the current model.",
|
|
406
|
+
"",
|
|
407
|
+
"Recent conversation:",
|
|
408
|
+
excerpt === "" ? "(no messages yet)" : excerpt,
|
|
409
|
+
"",
|
|
410
|
+
question,
|
|
411
|
+
].join("\n");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Plain-text blocks of one message content array, joined by newlines. */
|
|
415
|
+
function textBlocksOf(content) {
|
|
416
|
+
if (!Array.isArray(content)) return "";
|
|
417
|
+
return content
|
|
418
|
+
.filter((block) => block !== null && typeof block === "object" && block.type === "text")
|
|
419
|
+
.map((block) => (typeof block.text === "string" ? block.text : ""))
|
|
420
|
+
.join("\n");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Recent user/assistant conversation excerpt for the classifier (design §3;
|
|
425
|
+
* extraction mirrors session-title's user/message eligibility: text blocks
|
|
426
|
+
* joined with "\n"), newest-first with a total character budget.
|
|
427
|
+
*/
|
|
428
|
+
function sessionHistoryOf(ctx, sessionId, config) {
|
|
429
|
+
if (sessionId === null) return "";
|
|
430
|
+
const maxMessages = Math.max(1, Math.round(readNumber(config.intentHistoryMessages, 6)));
|
|
431
|
+
const maxChars = Math.max(0, Math.round(readNumber(config.intentHistoryMaxChars, 2400)));
|
|
432
|
+
try {
|
|
433
|
+
const session = ctx.sessions?.get?.(sessionId);
|
|
434
|
+
const events = typeof session?.snapshotEvents === "function" ? session.snapshotEvents() : [];
|
|
435
|
+
if (!Array.isArray(events)) return "";
|
|
436
|
+
const picked = [];
|
|
437
|
+
let budget = maxChars;
|
|
438
|
+
for (let i = events.length - 1; i >= 0 && picked.length < maxMessages && budget > 0; i--) {
|
|
439
|
+
const event = events[i];
|
|
440
|
+
const data = event?.data ?? null;
|
|
441
|
+
const isUser = event?.type === "user/message" && data?.source?.kind === "user";
|
|
442
|
+
const isAssistant = event?.type === "assistant/message";
|
|
443
|
+
if (!isUser && !isAssistant) continue;
|
|
444
|
+
const message = isUser ? data : data?.message;
|
|
445
|
+
const text = textBlocksOf(message?.content).trim();
|
|
446
|
+
if (text === "") continue;
|
|
447
|
+
const prefix = isUser ? "User" : "Assistant";
|
|
448
|
+
const room = budget - prefix.length - 2;
|
|
449
|
+
if (room <= 0) break;
|
|
450
|
+
const body = text.length > room ? `${text.slice(0, room)}…` : text;
|
|
451
|
+
picked.unshift(`${prefix}: ${body}`);
|
|
452
|
+
budget -= prefix.length + 2 + body.length;
|
|
453
|
+
}
|
|
454
|
+
return picked.join("\n");
|
|
455
|
+
} catch {
|
|
456
|
+
return ""; // unreadable history degrades to an empty excerpt, never throws
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Session event snapshot for turn-key extraction (empty array on any
|
|
461
|
+
* unreadable state — the pin then degrades to the no-user key, never
|
|
462
|
+
* throws; same defensive ctx.sessions access as sessionHistoryOf). */
|
|
463
|
+
function snapshotEventsOf(ctx, sessionId) {
|
|
464
|
+
try {
|
|
465
|
+
const session = ctx.sessions?.get?.(sessionId);
|
|
466
|
+
const events = typeof session?.snapshotEvents === "function" ? session.snapshotEvents() : [];
|
|
467
|
+
return Array.isArray(events) ? events : [];
|
|
468
|
+
} catch {
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The per-turn pin key: the id of the newest HUMAN user/message event
|
|
474
|
+
* (source.kind === "user"; seq fallback when the id is missing). Only the
|
|
475
|
+
* human kind counts: goal rounds (kind "goal"), compaction checkpoints and
|
|
476
|
+
* session recalls (kind "plugin"), and subagent-settled notices are
|
|
477
|
+
* user/message events too, but they are autonomous-work artifacts — keying
|
|
478
|
+
* on them would re-classify mid-work exactly like the bug this fixes. The
|
|
479
|
+
* guard mirrors sessionHistoryOf's excerpt eligibility, so the pin boundary
|
|
480
|
+
* and the classifier input stay consistent. GenerateOptions carries no
|
|
481
|
+
* turn/step (the abandoned agent/request waterfall did), so this event is
|
|
482
|
+
* the only turn boundary visible at the adapter's stream. (design §4,
|
|
483
|
+
* 2026-10-25) */
|
|
484
|
+
function turnKeyOf(sessionId, events) {
|
|
485
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
486
|
+
const event = events[i];
|
|
487
|
+
if (event?.type !== "user/message") continue;
|
|
488
|
+
const data = event?.data;
|
|
489
|
+
if (data?.source?.kind !== "user") continue;
|
|
490
|
+
const id = typeof data?.id === "string" && data.id !== "" ? data.id : `seq:${typeof event?.seq === "number" ? event.seq : i}`;
|
|
491
|
+
return `${sessionId}:${id}`;
|
|
492
|
+
}
|
|
493
|
+
return `${sessionId}:no-user`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
// Classifier call (session-title-llm pattern; local deadline, no new deps).
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
/** One user message literal (createUserMessage shape: role/content/source/id). */
|
|
501
|
+
function userMessage(text) {
|
|
502
|
+
return {
|
|
503
|
+
id: randomUUID(),
|
|
504
|
+
role: "user",
|
|
505
|
+
content: [{ type: "text", text }],
|
|
506
|
+
source: { kind: "dsh-switchman-intent" },
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Run one classification request and return the raw output text. Throws on
|
|
512
|
+
* timeout, abort, operational finish (error/aborted), or a missing llm
|
|
513
|
+
* service — callers fail open. The classifier route is a real provider, so
|
|
514
|
+
* the call cannot re-enter this adapter (real provider ≠ AUTO_PROVIDER).
|
|
515
|
+
*/
|
|
516
|
+
async function classifyOnce(ctx, config, route, system, input, turnSignal) {
|
|
517
|
+
const llm = typeof ctx.get === "function" ? ctx.get("llm", false) : undefined;
|
|
518
|
+
if (llm === undefined || llm === null || typeof llm.prepareCall !== "function" || typeof llm.stream !== "function") {
|
|
519
|
+
throw new Error("llm service unavailable");
|
|
520
|
+
}
|
|
521
|
+
const deadlineMs = Math.max(200, Math.round(readNumber(config.intentTimeoutMs, 60000)));
|
|
522
|
+
const controller = new AbortController();
|
|
523
|
+
const timer = setTimeout(() => controller.abort(), deadlineMs);
|
|
524
|
+
const signal = turnSignal instanceof AbortSignal ? AbortSignal.any([controller.signal, turnSignal]) : controller.signal;
|
|
525
|
+
try {
|
|
526
|
+
const effort = readField(config.intentClassifierEffort).trim();
|
|
527
|
+
const call = await llm.prepareCall(
|
|
528
|
+
{ provider: route.provider, model: route.model, maxTokens: 4096 },
|
|
529
|
+
signal,
|
|
530
|
+
(controls) => (effort === "" ? controls : { ...controls, reasoningEffort: effort }),
|
|
531
|
+
);
|
|
532
|
+
const options = {
|
|
533
|
+
...call.config,
|
|
534
|
+
messages: [userMessage(input)],
|
|
535
|
+
system,
|
|
536
|
+
signal,
|
|
537
|
+
};
|
|
538
|
+
let text = "";
|
|
539
|
+
let finish = null;
|
|
540
|
+
for await (const chunk of call.stream(options)) {
|
|
541
|
+
signal.throwIfAborted();
|
|
542
|
+
if (chunk === null || typeof chunk !== "object") continue;
|
|
543
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
|
|
544
|
+
else if (chunk.type === "finish") finish = chunk.reason ?? null;
|
|
545
|
+
}
|
|
546
|
+
signal.throwIfAborted();
|
|
547
|
+
if (finish !== null && typeof finish === "object" && (finish.kind === "error" || finish.kind === "aborted")) {
|
|
548
|
+
throw new Error(finish.failure?.message ?? "classifier stream failed");
|
|
549
|
+
}
|
|
550
|
+
return text;
|
|
551
|
+
} finally {
|
|
552
|
+
clearTimeout(timer);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Classify via an ollama /v1/systemone decision endpoint (direct HTTP —
|
|
558
|
+
* no llm-service provider registration needed; the model name comes from
|
|
559
|
+
* intentClassifierSystemoneModel, the URL from intentClassifierEndpoint).
|
|
560
|
+
* Contract: POST {model, state, questions:{best:{type:"choice",
|
|
561
|
+
* instructions, criteria:{key: description}}}} → answers.best.{choice,
|
|
562
|
+
* probabilities, confidence}. Returns a verdict {best, confidence} with
|
|
563
|
+
* confidence scaled to 0-100 (top probability preferred over the model's
|
|
564
|
+
* calibrated `confidence`). Throws on unset config, timeout/abort/HTTP/
|
|
565
|
+
* parse errors — callers fail open. Exported for tests.
|
|
566
|
+
*/
|
|
567
|
+
export async function classifySystemone(ctx, config, candidates, current, excerpt, turnSignal) {
|
|
568
|
+
const endpoint = readField(config.intentClassifierEndpoint).trim();
|
|
569
|
+
if (endpoint === "") throw new Error("systemone endpoint unset (intentClassifierEndpoint)");
|
|
570
|
+
const model = readField(config.intentClassifierSystemoneModel).trim();
|
|
571
|
+
if (model === "") throw new Error("systemone model unset (intentClassifierSystemoneModel)");
|
|
572
|
+
if (candidates.length === 0) throw new Error("no candidates");
|
|
573
|
+
const currentKey = current === null ? "\u0000-none" : routeKeyOf(current);
|
|
574
|
+
const keys = [];
|
|
575
|
+
const criteria = {};
|
|
576
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
577
|
+
const key = `#${index} ${candidate.provider}/${candidate.model}${routeKeyOf(candidate) === currentKey ? " (current)" : ""}`;
|
|
578
|
+
keys.push(key);
|
|
579
|
+
criteria[key] = typeof candidate.note === "string" && candidate.note !== "" ? candidate.note : "no specific note";
|
|
580
|
+
}
|
|
581
|
+
const state = [
|
|
582
|
+
"Candidates:",
|
|
583
|
+
...candidateLines(candidates, currentKey),
|
|
584
|
+
"When no candidate has a clear advantage, prefer the current model.",
|
|
585
|
+
"",
|
|
586
|
+
"Recent conversation:",
|
|
587
|
+
excerpt === "" ? "(no messages yet)" : excerpt,
|
|
588
|
+
].join("\n");
|
|
589
|
+
const payload = {
|
|
590
|
+
model,
|
|
591
|
+
state,
|
|
592
|
+
questions: {
|
|
593
|
+
best: {
|
|
594
|
+
type: "choice",
|
|
595
|
+
instructions: "Which candidate model should answer the next turn? When no candidate has a clear advantage, prefer the current model.",
|
|
596
|
+
criteria,
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
const deadlineMs = Math.max(200, Math.round(readNumber(config.intentTimeoutMs, 60000)));
|
|
601
|
+
const controller = new AbortController();
|
|
602
|
+
const timer = setTimeout(() => controller.abort(), deadlineMs);
|
|
603
|
+
const signal = turnSignal instanceof AbortSignal ? AbortSignal.any([controller.signal, turnSignal]) : controller.signal;
|
|
604
|
+
try {
|
|
605
|
+
signal.throwIfAborted();
|
|
606
|
+
const response = await fetch(endpoint, {
|
|
607
|
+
method: "POST",
|
|
608
|
+
headers: { "Content-Type": "application/json" },
|
|
609
|
+
body: JSON.stringify(payload),
|
|
610
|
+
signal,
|
|
611
|
+
});
|
|
612
|
+
if (!response.ok) throw new Error(`systemone HTTP ${response.status}`);
|
|
613
|
+
const body = await response.json();
|
|
614
|
+
const answer = body?.answers?.best;
|
|
615
|
+
const choice = typeof answer?.choice === "string" ? answer.choice : null;
|
|
616
|
+
const best = choice === null ? -1 : keys.indexOf(choice);
|
|
617
|
+
if (best < 0) throw new Error(`systemone choice unknown: ${JSON.stringify(choice)}`);
|
|
618
|
+
const probabilities = answer?.probabilities !== null && typeof answer?.probabilities === "object" ? answer.probabilities : null;
|
|
619
|
+
const top = probabilities !== null ? numberOr(probabilities[choice]) : null;
|
|
620
|
+
const calibrated = numberOr(answer?.confidence);
|
|
621
|
+
const score = top !== null && top >= 0 && top <= 1 ? top : calibrated !== null && calibrated >= 0 && calibrated <= 1 ? calibrated : null;
|
|
622
|
+
// No usable confidence means the verdict carries zero evidence; treat it as a
|
|
623
|
+
// classification failure so the shared catch fails open (fallback chain keeps
|
|
624
|
+
// the current model) instead of committing a confidence-0 switch.
|
|
625
|
+
if (score === null) throw new Error("systemone answer carried no usable confidence");
|
|
626
|
+
return { best, confidence: Math.round(score * 1000) / 10 };
|
|
627
|
+
} finally {
|
|
628
|
+
clearTimeout(timer);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
// Rewrite + routing entry point.
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
|
|
636
|
+
/** Decide and apply one auto request (design §4 steps 2-4). Returns the
|
|
637
|
+
* decided route ({provider, model, effort?}) — reasoningEffort only when
|
|
638
|
+
* the route pins one (any effort the user picked on the pseudo model is
|
|
639
|
+
* dropped: the pseudo resolveModel declares no reasoning metadata, so a
|
|
640
|
+
* leaked effort would hard-fail prepareCall with
|
|
641
|
+
* UNSUPPORTED_REASONING_EFFORT) — or null when nothing is configured. */
|
|
642
|
+
async function routeAutoRequest(ctx, config, payload, warn) {
|
|
643
|
+
const enabled = readBool(config.intentEnabled);
|
|
644
|
+
const candidates = readCandidates(config.intentCandidates);
|
|
645
|
+
const fallback = readRoute(config.intentFallbackRoute);
|
|
646
|
+
const rawSessionId = payload?.agent?.session?.id;
|
|
647
|
+
const sessionId = typeof rawSessionId === "string" && rawSessionId !== "" ? rawSessionId : null;
|
|
648
|
+
const state = sessionId === null ? null : stateOf(sessionId);
|
|
649
|
+
// Turn pin keyed by the newest human message (turnKeyOf): one
|
|
650
|
+
// classification per user message; every model call of the agent loop
|
|
651
|
+
// within that message reuses the decision. GenerateOptions has no
|
|
652
|
+
// turn/step, so the old numeric-turn pin could never fire — every step
|
|
653
|
+
// re-classified, observed as the dock chip's decision churning mid-work
|
|
654
|
+
// (2026-10-25 fix; design §4 note).
|
|
655
|
+
const turnKey = sessionId === null ? null : turnKeyOf(sessionId, snapshotEventsOf(ctx, sessionId));
|
|
656
|
+
if (state !== null && turnKey !== null && state.pinnedTurnKey === turnKey && routeOf(state.decision) !== null) {
|
|
657
|
+
// Pin hit = another call routed on the decided model: the provider
|
|
658
|
+
// prompt cache is being hit again, so the rolling window renews (the
|
|
659
|
+
// §4 keep-branch rule — without this, any turn longer than
|
|
660
|
+
// intentCacheTtlSeconds would freeze the window and the next message
|
|
661
|
+
// would switch at sub-threshold confidence via switch-cache-expired,
|
|
662
|
+
// discarding a cache that was hit all turn). lastDecision stays
|
|
663
|
+
// untouched — it timestamps the decision, not the last use.
|
|
664
|
+
state.routedAt = Date.now();
|
|
665
|
+
return state.decision;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const classifier = readRoute(config.intentClassifierRoute);
|
|
669
|
+
const kind = readField(config.intentClassifierKind);
|
|
670
|
+
const now = Date.now();
|
|
671
|
+
let parsed = null;
|
|
672
|
+
// systemone needs no classifier route at all (endpoint + model are
|
|
673
|
+
// hand-configured); the chat kind requires one. Either way a missing
|
|
674
|
+
// prerequisite only skips classification — the fallback chain decides.
|
|
675
|
+
if (enabled && candidates.length > 0 && (kind === "systemone" || classifier !== null)) {
|
|
676
|
+
try {
|
|
677
|
+
const excerpt = sessionHistoryOf(ctx, sessionId, config);
|
|
678
|
+
const current = routeOf(state?.routedCurrent) === null ? null : state.routedCurrent;
|
|
679
|
+
if (kind === "systemone") {
|
|
680
|
+
// Direct decision endpoint — skips the llm service entirely;
|
|
681
|
+
// model and URL are hand-configured, no provider involved.
|
|
682
|
+
parsed = await classifySystemone(ctx, config, candidates, current, excerpt, payload?.signal);
|
|
683
|
+
} else {
|
|
684
|
+
const system = classifierSystemPrompt();
|
|
685
|
+
const input = classifierInput(config, candidates, current, excerpt);
|
|
686
|
+
const text = await classifyOnce(ctx, config, classifier, system, input, payload?.signal);
|
|
687
|
+
parsed = parseClassifierVerdict(text, candidates.length);
|
|
688
|
+
if (parsed === null) warn("intent: classifier output unparseable; using the fallback chain");
|
|
689
|
+
}
|
|
690
|
+
} catch (error) {
|
|
691
|
+
warn(`intent: classification failed (${error?.message ?? error}); using the fallback chain`);
|
|
692
|
+
parsed = null;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const verdict = decideRoute({
|
|
696
|
+
candidates,
|
|
697
|
+
current: state === null ? null : state.routedCurrent,
|
|
698
|
+
parsed,
|
|
699
|
+
now,
|
|
700
|
+
routedAt: state === null ? null : state.routedAt,
|
|
701
|
+
cacheTtlSeconds: readNumber(config.intentCacheTtlSeconds, 300),
|
|
702
|
+
switchConfidence: readNumber(config.intentSwitchConfidence, 80),
|
|
703
|
+
fallback,
|
|
704
|
+
});
|
|
705
|
+
if (verdict.route === null) return null; // nothing configured — caller degrades
|
|
706
|
+
if (state !== null) {
|
|
707
|
+
state.pinnedTurnKey = turnKey;
|
|
708
|
+
state.decision = verdict.route;
|
|
709
|
+
state.routedCurrent = verdict.route;
|
|
710
|
+
state.routedAt = now; // provider prompt caches refresh on hit — the window restarts
|
|
711
|
+
state.lastDecision = {
|
|
712
|
+
best: `${verdict.route.provider}/${verdict.route.model}`,
|
|
713
|
+
confidence: parsed === null ? 0 : parsed.confidence,
|
|
714
|
+
at: now,
|
|
715
|
+
switched: verdict.switched === true,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
return verdict.route;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Live intent-routing state for the client route (design §5 shape).
|
|
723
|
+
* @param {import("@deepseek-ai/cordis").Context} ctx - host plugin context.
|
|
724
|
+
* @param {object} config - this plugin's live volatile config.
|
|
725
|
+
* @param {string | null} [sessionId] - session to report (null = root-only
|
|
726
|
+
* fields; autoSelected needs a session).
|
|
727
|
+
*/
|
|
728
|
+
export async function intentStateOf(ctx, config, sessionId = null) {
|
|
729
|
+
const enabled = readBool(config.intentEnabled);
|
|
730
|
+
const kind = readField(config.intentClassifierKind);
|
|
731
|
+
const candidates = readCandidates(config.intentCandidates);
|
|
732
|
+
const classifier = readRoute(config.intentClassifierRoute);
|
|
733
|
+
const state = typeof sessionId === "string" && sessionId !== "" ? sessionState.get(sessionId) : undefined;
|
|
734
|
+
const selection = typeof sessionId === "string" && sessionId !== "" ? await sessionModelSelectionOf(ctx, sessionId) : null;
|
|
735
|
+
const now = Date.now();
|
|
736
|
+
const ttlMs = Math.max(0, readNumber(config.intentCacheTtlSeconds, 300)) * 1000;
|
|
737
|
+
// Smallest REAL context window across the candidates, probed through
|
|
738
|
+
// llm.resolveModelInfo. The call MUST stay receiver-bound (the
|
|
739
|
+
// host/vision.js lesson): LlmRuntime methods read `this`, so fetch the
|
|
740
|
+
// service via ctx.get and call ON the llm object — a detached call
|
|
741
|
+
// TypeErrors into null. Sequential awaits, one try/catch per candidate;
|
|
742
|
+
// every failure (missing service, unknown model, throw, non-finite
|
|
743
|
+
// value) reads as null = "don't know", never throws, fail-open.
|
|
744
|
+
let llm = null;
|
|
745
|
+
try {
|
|
746
|
+
const service = ctx.get?.("llm", false);
|
|
747
|
+
if (service !== null && typeof service === "object" && typeof service.resolveModelInfo === "function") llm = service;
|
|
748
|
+
} catch {
|
|
749
|
+
llm = null;
|
|
750
|
+
}
|
|
751
|
+
let minCandidateWindow = null;
|
|
752
|
+
let minCandidateModel = null;
|
|
753
|
+
if (llm !== null) {
|
|
754
|
+
for (const candidate of candidates) {
|
|
755
|
+
let window = null;
|
|
756
|
+
try {
|
|
757
|
+
const info = await llm.resolveModelInfo(candidate.provider, candidate.model);
|
|
758
|
+
const value = info?.context?.contextWindow;
|
|
759
|
+
window = typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
|
760
|
+
} catch {
|
|
761
|
+
window = null;
|
|
762
|
+
}
|
|
763
|
+
if (window !== null && (minCandidateWindow === null || window < minCandidateWindow)) {
|
|
764
|
+
minCandidateWindow = window;
|
|
765
|
+
minCandidateModel = candidate.model;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const forceTokens = readNumber(config.wmForceTokens, 150000);
|
|
770
|
+
return {
|
|
771
|
+
enabled,
|
|
772
|
+
autoSelected: selection?.provider === AUTO_PROVIDER,
|
|
773
|
+
// systemone runs on the hand-configured endpoint + model, so a blank
|
|
774
|
+
// catalog route must not mark the feature unconfigured.
|
|
775
|
+
configured:
|
|
776
|
+
(kind === "systemone"
|
|
777
|
+
? readField(config.intentClassifierEndpoint).trim() !== "" &&
|
|
778
|
+
readField(config.intentClassifierSystemoneModel).trim() !== ""
|
|
779
|
+
: classifier !== null) &&
|
|
780
|
+
candidates.length > 0,
|
|
781
|
+
current: routeOf(state?.routedCurrent) === null ? null : { provider: state.routedCurrent.provider, model: state.routedCurrent.model },
|
|
782
|
+
lastDecision: state?.lastDecision ?? null,
|
|
783
|
+
cacheWindowRemainingMs:
|
|
784
|
+
typeof state?.routedAt === "number" ? Math.max(0, Math.round(state.routedAt + ttlMs - now)) : 0,
|
|
785
|
+
context: {
|
|
786
|
+
// Effective declared window (the handover floor applies — see
|
|
787
|
+
// effectiveContextWindow) plus the smallest candidate window
|
|
788
|
+
// probe. overflowRisk is informational ONLY (drives a dock
|
|
789
|
+
// tooltip line): a candidate narrower than the force watermark
|
|
790
|
+
// may overflow near the cap before the handover line — it never
|
|
791
|
+
// gates routing here.
|
|
792
|
+
contextWindow: effectiveContextWindow(config),
|
|
793
|
+
minCandidateWindow,
|
|
794
|
+
// The route owning that minimum rides along so the client can
|
|
795
|
+
// name the risky model; null exactly when minCandidateWindow is.
|
|
796
|
+
minCandidateModel: minCandidateWindow === null ? null : minCandidateModel,
|
|
797
|
+
overflowRisk: minCandidateWindow !== null && minCandidateWindow < forceTokens,
|
|
798
|
+
},
|
|
799
|
+
candidates: candidates.map((candidate) => ({
|
|
800
|
+
provider: candidate.provider,
|
|
801
|
+
model: candidate.model,
|
|
802
|
+
...(typeof candidate.note === "string" && candidate.note !== "" ? { note: candidate.note } : {}),
|
|
803
|
+
})),
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ---------------------------------------------------------------------------
|
|
808
|
+
// The pseudo adapter (catalog entry + the routing stream).
|
|
809
|
+
// ---------------------------------------------------------------------------
|
|
810
|
+
|
|
811
|
+
/** Options object → decided route. Llm-layer retries re-dispatch the SAME
|
|
812
|
+
* options object; the pin makes them reuse the decision instead of
|
|
813
|
+
* re-classifying (and possibly flip-flopping mid-retry). */
|
|
814
|
+
const requestPin = new WeakMap();
|
|
815
|
+
|
|
816
|
+
/** Build the dsh-switchman-auto adapter object (LlmAdapter protocol). We ship
|
|
817
|
+
* a plain object, which inherits NOTHING from the harness base class — every
|
|
818
|
+
* member the registry or the stream path touches must be present here. */
|
|
819
|
+
function createAutoAdapter(ctx, config, warn) {
|
|
820
|
+
return {
|
|
821
|
+
providerInfo: (provider) => ({ id: provider, name: "Auto · Switchman" }),
|
|
822
|
+
/** Required by the registry's prepareRoutes (called unconditionally);
|
|
823
|
+
* returning undefined lets the registry apply its default retry policy. */
|
|
824
|
+
providerRetryPolicy: () => undefined,
|
|
825
|
+
/** Base-class default: declare no provider-owned image pricing so the
|
|
826
|
+
* token meter falls back to its neutral estimate. The registry reads
|
|
827
|
+
* this for registered routes when pricing durable history. */
|
|
828
|
+
imageRequestPricing: () => undefined,
|
|
829
|
+
/** Base-class default: preserve normalized images untouched. */
|
|
830
|
+
imageRequestTarget: (_provider, _model, image) => ({ width: image.width, height: image.height, maxBytes: image.bytes }),
|
|
831
|
+
/** Catalog entries, decided per call from the live config: OFF hides
|
|
832
|
+
* the group (the selector drops adapters whose listModels yields an
|
|
833
|
+
* empty array) while the adapter stays mounted, so flipping the
|
|
834
|
+
* toggle re-exposes Auto with no remount, and a session already on
|
|
835
|
+
* Auto keeps routing through the documented fallback chain (no
|
|
836
|
+
* classifier call). */
|
|
837
|
+
listModels: async (provider) =>
|
|
838
|
+
readBool(config.intentEnabled)
|
|
839
|
+
? [{ provider, id: AUTO_MODEL, name: "Auto", description: "Intent-based model routing by switchman" }]
|
|
840
|
+
: [],
|
|
841
|
+
resolveModel: async (provider, model) => ({
|
|
842
|
+
provider,
|
|
843
|
+
id: model,
|
|
844
|
+
name: "Auto",
|
|
845
|
+
context: { contextWindow: effectiveContextWindow(config) },
|
|
846
|
+
}),
|
|
847
|
+
/** Base-class default (replicated): the registry's stream path calls
|
|
848
|
+
* prepareCall and dispatches through the returned stream — it never
|
|
849
|
+
* calls adapter.stream directly. */
|
|
850
|
+
async prepareCall(provider, model, signal) {
|
|
851
|
+
return {
|
|
852
|
+
model: await this.resolveModel(provider, model, signal),
|
|
853
|
+
stream: (options) => this.stream(options),
|
|
854
|
+
};
|
|
855
|
+
},
|
|
856
|
+
/** THE routing point (see the file header): classify, advance the
|
|
857
|
+
* state machine, forward to the decided route on a NEW options
|
|
858
|
+
* object — a real provider cannot re-enter this adapter. */
|
|
859
|
+
async *stream(options) {
|
|
860
|
+
const sessionId = typeof options?.sessionId === "string" ? options.sessionId : null;
|
|
861
|
+
let route = requestPin.get(options);
|
|
862
|
+
if (route === undefined) {
|
|
863
|
+
route = await routeAutoRequest(ctx, config, { agent: { session: { id: sessionId } }, signal: options?.signal }, warn);
|
|
864
|
+
if (route !== null) requestPin.set(options, route);
|
|
865
|
+
}
|
|
866
|
+
// routeAutoRequest returning null means nothing is routable
|
|
867
|
+
// (disabled + no fallback + no candidates) and the caller was
|
|
868
|
+
// supposed to degrade — but the adapter IS the last caller, so
|
|
869
|
+
// the contract ends here: fail loudly and legibly instead of the
|
|
870
|
+
// old TypeError on `route.provider`, telling the user the fix.
|
|
871
|
+
if (route === null)
|
|
872
|
+
throw new Error(
|
|
873
|
+
"dsh-switchman auto: no route available (intent routing disabled, no fallback model, no candidates) — pick a concrete model",
|
|
874
|
+
);
|
|
875
|
+
const { reasoningEffort: _droppedPseudoEffort, ...rest } = options ?? {};
|
|
876
|
+
const forwarded = {
|
|
877
|
+
...rest,
|
|
878
|
+
provider: route.provider,
|
|
879
|
+
model: route.model,
|
|
880
|
+
...(typeof route.effort === "string" && route.effort !== "" ? { reasoningEffort: route.effort } : {}),
|
|
881
|
+
};
|
|
882
|
+
yield* ctx.get("llm", false).stream(forwarded);
|
|
883
|
+
},
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// ---------------------------------------------------------------------------
|
|
888
|
+
// Mount.
|
|
889
|
+
// ---------------------------------------------------------------------------
|
|
890
|
+
|
|
891
|
+
/** Warn-once latch for the missing-llm-service degrade. */
|
|
892
|
+
let warnedUnavailable = false;
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Mount the intent-routing layer: the pseudo adapter and the session/disposed
|
|
896
|
+
* cleanup. (Routing itself lives in the adapter's stream — see the header.)
|
|
897
|
+
* The adapter mounts REGARDLESS of intentEnabled — a degraded-resident OFF
|
|
898
|
+
* mode: listModels returns [] so the selector hides the Auto group (see
|
|
899
|
+
* createAutoAdapter), and a session already on Auto keeps streaming through
|
|
900
|
+
* the documented fallback chain with no classifier call.
|
|
901
|
+
*
|
|
902
|
+
* The llm service is deliberately NOT in the plugin's `inject` list (see
|
|
903
|
+
* index.js): a deployment without it must still mount the rest of the plugin.
|
|
904
|
+
* `ctx.inject(["llm"])` defers this feature's mount until the service is
|
|
905
|
+
* stored (immediately when it already is) and re-runs it cleanly if the
|
|
906
|
+
* service is ever replaced. Two timing traps this closes:
|
|
907
|
+
* 1. A one-shot `ctx.get("llm", false)` at apply time loses the race against
|
|
908
|
+
* the llm fiber at boot — the layer silently no-ops forever (observed in
|
|
909
|
+
* the field as the composer picker missing the Auto group).
|
|
910
|
+
* 2. `registerAdapter`'s backing effect lives on the LLM fiber, not ours, so
|
|
911
|
+
* without an explicit release any plugin reload (any settings change)
|
|
912
|
+
* re-registers onto a still-owned provider and dies on DUPLICATE_ADAPTER.
|
|
913
|
+
* @param {import("@deepseek-ai/cordis").Context} ctx - host plugin context.
|
|
914
|
+
* @param {object} config - this plugin's live volatile config.
|
|
915
|
+
*/
|
|
916
|
+
export function applyIntent(ctx, config) {
|
|
917
|
+
const warn = (message) => ctx.logger?.warn?.(`dsh-switchman: ${message}`);
|
|
918
|
+
ctx.inject(["llm"], (llmCtx) => {
|
|
919
|
+
try {
|
|
920
|
+
const llm = llmCtx.llm;
|
|
921
|
+
if (llm === undefined || llm === null || typeof llm.registerAdapter !== "function" || typeof llm.prepareCall !== "function" || typeof llm.stream !== "function") {
|
|
922
|
+
if (!warnedUnavailable) {
|
|
923
|
+
warnedUnavailable = true;
|
|
924
|
+
warn("intent routing unavailable: llm service malformed (documented runtime precondition); layer stays a no-op");
|
|
925
|
+
}
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const handle = llm.registerAdapter([AUTO_PROVIDER], createAutoAdapter(llmCtx, config, warn));
|
|
929
|
+
// Release on OUR fiber: the disposal runs synchronously inside the
|
|
930
|
+
// sub-fiber's unload, which cordis fully drains before any re-apply,
|
|
931
|
+
// so reloads can never race into DUPLICATE_ADAPTER.
|
|
932
|
+
llmCtx.effect(function* () {
|
|
933
|
+
yield () => {
|
|
934
|
+
try {
|
|
935
|
+
handle();
|
|
936
|
+
} catch {
|
|
937
|
+
/* release is best-effort */
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
}, "dsh-switchman:intent-auto-adapter-release");
|
|
941
|
+
llmCtx.on("session/disposed", (session) => {
|
|
942
|
+
try {
|
|
943
|
+
if (session !== null && typeof session === "object" && typeof session.id === "string") clearSessionState(session.id);
|
|
944
|
+
} catch {
|
|
945
|
+
/* cleanup is best-effort */
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
} catch (error) {
|
|
949
|
+
warn(`intent routing layer failed to mount: ${error?.message ?? error}`);
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
}
|