@ejstembler/pi-classifier-router 1.0.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.md +519 -0
- package/examples/class-router.json +68 -0
- package/examples/class-router.pi.json +47 -0
- package/package.json +55 -0
- package/python/laya_worker.py +203 -0
- package/python/requirements.txt +10 -0
- package/src/breaker.ts +127 -0
- package/src/classify/http.ts +176 -0
- package/src/classify/index.ts +34 -0
- package/src/classify/jev.ts +23 -0
- package/src/classify/laya-http.ts +28 -0
- package/src/classify/laya.ts +383 -0
- package/src/config.ts +288 -0
- package/src/host.ts +363 -0
- package/src/index.ts +793 -0
- package/src/router.ts +135 -0
- package/src/types.ts +299 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Class Router: classify each prompt with a System-One backend (Jev over HTTP,
|
|
3
|
+
* or Laya through a local Python sidecar) and route the session to the best
|
|
4
|
+
* model, with a per-spec circuit breaker and fallback chains.
|
|
5
|
+
*
|
|
6
|
+
* Types come from the upstream pi extension API so one entry point loads on
|
|
7
|
+
* both pi and Oh My Pi (omp); omp-only capabilities are feature-detected through
|
|
8
|
+
* `./host.ts`. Load-time work is registration only. Every runtime behaviour
|
|
9
|
+
* hangs off an event handler or the `/class-router` command, and nothing on the
|
|
10
|
+
* routing path may break a turn: a failed classification degrades to the
|
|
11
|
+
* session's own model.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
import { CircuitBreaker } from "./breaker.ts";
|
|
19
|
+
import type { BreakerSnapshot } from "./breaker.ts";
|
|
20
|
+
import { createClassifier } from "./classify/index.ts";
|
|
21
|
+
import { defaultConfig, loadConfig } from "./config.ts";
|
|
22
|
+
import type { ConfigSource } from "./config.ts";
|
|
23
|
+
import {
|
|
24
|
+
applyModel,
|
|
25
|
+
currentModel,
|
|
26
|
+
detectHost,
|
|
27
|
+
hasConfiguredAuth,
|
|
28
|
+
log,
|
|
29
|
+
registerEvent,
|
|
30
|
+
resolveModel,
|
|
31
|
+
startTimer,
|
|
32
|
+
} from "./host.ts";
|
|
33
|
+
import { decide } from "./router.ts";
|
|
34
|
+
import { ClassifierError } from "./types.ts";
|
|
35
|
+
import type {
|
|
36
|
+
Answer,
|
|
37
|
+
ClassificationResult,
|
|
38
|
+
Classifier,
|
|
39
|
+
Decision,
|
|
40
|
+
DecisionReason,
|
|
41
|
+
RouterConfig,
|
|
42
|
+
} from "./types.ts";
|
|
43
|
+
|
|
44
|
+
const NOTIFY_PREFIX = "[class-router]";
|
|
45
|
+
const HISTORY_LIMIT = 50;
|
|
46
|
+
/** Slack between the backend's own timeout and the outer "never hang" guard. */
|
|
47
|
+
const HARD_GUARD_SLACK_MS = 250;
|
|
48
|
+
const SUBCOMMANDS = ["status", "on", "off", "reset", "explain"] as const;
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Failure classification
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export type FailureReason = "rate_limit" | "overloaded" | "auth" | "transport" | "other";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Map a provider error string onto the breaker's coarse failure taxonomy.
|
|
58
|
+
*
|
|
59
|
+
* The order matters: a 429 that also mentions a timeout is a rate limit, and
|
|
60
|
+
* an aborted request is transport noise even when the message embeds a status.
|
|
61
|
+
*/
|
|
62
|
+
export function classifyFailure(errorMessage: string): FailureReason {
|
|
63
|
+
const text = errorMessage.toLowerCase();
|
|
64
|
+
if (/(\b429\b|rate[ _-]?limit|too many requests|quota)/.test(text)) return "rate_limit";
|
|
65
|
+
if (/(\b503\b|overloaded|capacity|temporarily unavailable)/.test(text)) return "overloaded";
|
|
66
|
+
if (/(\b401\b|\b403\b|invalid api key|unauthorized|api key|authentication)/.test(text)) return "auth";
|
|
67
|
+
if (/(abort|econnreset|etimedout|econnrefused|socket hang up|network|fetch failed)/.test(text)) return "transport";
|
|
68
|
+
return "other";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Per-session state
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
interface RouteRecord {
|
|
76
|
+
category: string | null;
|
|
77
|
+
confidence: number;
|
|
78
|
+
spec: string | null;
|
|
79
|
+
apply: string | null;
|
|
80
|
+
reason: DecisionReason;
|
|
81
|
+
backend: RouterConfig["backend"];
|
|
82
|
+
latencyMs: number;
|
|
83
|
+
at: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface PendingFailure {
|
|
87
|
+
reason: FailureReason;
|
|
88
|
+
message: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface SessionState {
|
|
92
|
+
sessionId: string;
|
|
93
|
+
cwd: string;
|
|
94
|
+
config: RouterConfig;
|
|
95
|
+
source: ConfigSource | null;
|
|
96
|
+
errors: string[];
|
|
97
|
+
sourceNotified: boolean;
|
|
98
|
+
authNotified: boolean;
|
|
99
|
+
lastDecision: Decision | null;
|
|
100
|
+
lastRecord: RouteRecord | null;
|
|
101
|
+
/** Spec this extension last applied, or null when it changed nothing. */
|
|
102
|
+
appliedSpec: string | null;
|
|
103
|
+
/** Model id that spec resolved to, for "is it still our model?" comparisons. */
|
|
104
|
+
appliedModelId: string | null;
|
|
105
|
+
pendingFailure: PendingFailure | null;
|
|
106
|
+
/** One breaker failure per run, so a retry storm is not counted repeatedly. */
|
|
107
|
+
failureRecorded: boolean;
|
|
108
|
+
history: RouteRecord[];
|
|
109
|
+
routed: number;
|
|
110
|
+
skipped: number;
|
|
111
|
+
failed: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sessions = new Map<string, SessionState>();
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One classifier/breaker pair for the process. The classifier is rebuilt only
|
|
118
|
+
* when the backend configuration changes, and is disposed by the session that
|
|
119
|
+
* created it.
|
|
120
|
+
*/
|
|
121
|
+
const runtime: {
|
|
122
|
+
classifier: Classifier | null;
|
|
123
|
+
classifierKey: string | null;
|
|
124
|
+
classifierOwner: string | null;
|
|
125
|
+
breaker: CircuitBreaker | null;
|
|
126
|
+
breakerKey: string | null;
|
|
127
|
+
} = {
|
|
128
|
+
classifier: null,
|
|
129
|
+
classifierKey: null,
|
|
130
|
+
classifierOwner: null,
|
|
131
|
+
breaker: null,
|
|
132
|
+
breakerKey: null,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
function sessionKey(ctx: ExtensionContext): string {
|
|
136
|
+
try {
|
|
137
|
+
return ctx.sessionManager.getSessionId();
|
|
138
|
+
} catch {
|
|
139
|
+
return "unknown";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Best-effort subagent detection.
|
|
145
|
+
*
|
|
146
|
+
* omp 18.2.9 exposes no supported "am I a subagent?" signal: `ExtensionContext`
|
|
147
|
+
* carries no agent kind, `session_start` carries no reason, `getHeader()` has no
|
|
148
|
+
* `parentSession` for task subagents (only for forks), and subagents share the
|
|
149
|
+
* parent's process. The session file path is the one reliable discriminator, so
|
|
150
|
+
* this depends on omp's on-disk session layout rather than an API guarantee:
|
|
151
|
+
* the default layout nests a subagent under `omp-task-<hex>/`, and an explicit
|
|
152
|
+
* `--session-dir` nests it under a directory whose basename is the parent's own
|
|
153
|
+
* `.jsonl` file. An unknown path fails safe toward MAIN.
|
|
154
|
+
*/
|
|
155
|
+
function isSubagentSession(ctx: ExtensionContext): boolean {
|
|
156
|
+
let file: string | undefined;
|
|
157
|
+
try {
|
|
158
|
+
file = ctx.sessionManager.getSessionFile();
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
if (typeof file !== "string" || file === "") return false;
|
|
163
|
+
if (/[/\\]omp-task-[0-9a-f]+[/\\]/.test(file)) return true;
|
|
164
|
+
return path.basename(path.dirname(file)).endsWith(".jsonl");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function getBreaker(config: RouterConfig, pi: ExtensionAPI): CircuitBreaker {
|
|
168
|
+
const key = JSON.stringify(config.circuitBreaker);
|
|
169
|
+
if (runtime.breaker !== null && runtime.breakerKey === key) return runtime.breaker;
|
|
170
|
+
runtime.breaker = new CircuitBreaker(config.circuitBreaker);
|
|
171
|
+
runtime.breakerKey = key;
|
|
172
|
+
log(pi, "debug", "class-router: circuit breaker configured", { config: config.circuitBreaker });
|
|
173
|
+
return runtime.breaker;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Rebuild the classifier only when the resolved backend configuration changes. */
|
|
177
|
+
function getClassifier(config: RouterConfig, sessionId: string, pi: ExtensionAPI): Classifier | null {
|
|
178
|
+
const key = JSON.stringify({ backend: config.backend, jev: config.jev, laya: config.laya });
|
|
179
|
+
if (runtime.classifier !== null && runtime.classifierKey === key) return runtime.classifier;
|
|
180
|
+
|
|
181
|
+
if (runtime.classifier !== null) {
|
|
182
|
+
void runtime.classifier.dispose().catch(() => undefined);
|
|
183
|
+
runtime.classifier = null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
runtime.classifier = createClassifier(config);
|
|
188
|
+
runtime.classifierKey = key;
|
|
189
|
+
runtime.classifierOwner = sessionId;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// A backend that cannot even be constructed must not take the extension down.
|
|
192
|
+
runtime.classifierKey = null;
|
|
193
|
+
runtime.classifierOwner = null;
|
|
194
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
195
|
+
log(pi, "warn", "class-router: classifier construction failed", { backend: config.backend, error: message });
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
return runtime.classifier;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Config loading
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Resolve the session's state, loading (or reloading, after a `/move`) config.
|
|
207
|
+
* A missing config file is not an error: defaults apply.
|
|
208
|
+
*/
|
|
209
|
+
function ensureSession(ctx: ExtensionContext, pi: ExtensionAPI): SessionState {
|
|
210
|
+
const sessionId = sessionKey(ctx);
|
|
211
|
+
let state = sessions.get(sessionId);
|
|
212
|
+
|
|
213
|
+
if (state !== undefined && state.cwd === ctx.cwd) return state;
|
|
214
|
+
|
|
215
|
+
const result = loadConfig(ctx.cwd);
|
|
216
|
+
const cwdChanged = state !== undefined && state.cwd !== ctx.cwd;
|
|
217
|
+
|
|
218
|
+
if (state === undefined) {
|
|
219
|
+
state = {
|
|
220
|
+
sessionId,
|
|
221
|
+
cwd: ctx.cwd,
|
|
222
|
+
config: result.config ?? defaultConfig(),
|
|
223
|
+
source: result.source,
|
|
224
|
+
errors: result.errors,
|
|
225
|
+
sourceNotified: false,
|
|
226
|
+
authNotified: false,
|
|
227
|
+
lastDecision: null,
|
|
228
|
+
lastRecord: null,
|
|
229
|
+
appliedSpec: null,
|
|
230
|
+
appliedModelId: null,
|
|
231
|
+
pendingFailure: null,
|
|
232
|
+
failureRecorded: false,
|
|
233
|
+
history: [],
|
|
234
|
+
routed: 0,
|
|
235
|
+
skipped: 0,
|
|
236
|
+
failed: 0,
|
|
237
|
+
};
|
|
238
|
+
sessions.set(sessionId, state);
|
|
239
|
+
} else {
|
|
240
|
+
state.cwd = ctx.cwd;
|
|
241
|
+
state.config = result.config ?? defaultConfig();
|
|
242
|
+
state.source = result.source;
|
|
243
|
+
state.errors = result.errors;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const error of result.errors) {
|
|
247
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} config error: ${oneLine(error)}`, "error");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (cwdChanged) {
|
|
251
|
+
// A reload resets runtime toggles, so say so instead of silently reverting.
|
|
252
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} config reloaded for ${ctx.cwd}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
reportSource(ctx, state);
|
|
256
|
+
getBreaker(state.config, pi);
|
|
257
|
+
return state;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function reportSource(ctx: ExtensionContext, state: SessionState): void {
|
|
261
|
+
if (state.sourceNotified || !state.config.notify) return;
|
|
262
|
+
state.sourceNotified = true;
|
|
263
|
+
const where = state.source === null ? "none (built-in defaults)" : `${state.source.path} (${state.source.scope})`;
|
|
264
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} config: ${oneLine(where)} | backend ${state.config.backend}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Formatting helpers
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/** Notifications are one line each; collapse any whitespace that slipped in. */
|
|
272
|
+
function oneLine(text: string): string {
|
|
273
|
+
return text.replace(/\s+/g, " ").trim();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function notify(ctx: ExtensionContext, state: SessionState | null, message: string, type?: "info" | "warning" | "error"): void {
|
|
277
|
+
if (state !== null && !state.config.notify && type !== "error") return;
|
|
278
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} ${oneLine(message)}`, type);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function formatAnswer(answer: Answer): string {
|
|
282
|
+
if (answer.type === "choice") return `choice=${answer.choice} (confidence ${answer.confidence.toFixed(2)})`;
|
|
283
|
+
if (answer.type === "score") return `score=${answer.score.toFixed(2)} (confidence ${answer.confidence.toFixed(2)})`;
|
|
284
|
+
return `noul=${answer.noul.toFixed(2)} (confidence ${answer.confidence.toFixed(2)})`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function formatBreaker(snapshot: BreakerSnapshot[]): string {
|
|
288
|
+
if (snapshot.length === 0) return "no circuits tracked";
|
|
289
|
+
return snapshot.map((entry) => `${entry.spec}=${entry.state}/${entry.failures}`).join(", ");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Classification
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Classify with the backend's own timeout plus an independent outer guard, so a
|
|
298
|
+
* backend that ignores its abort signal still cannot hang the turn.
|
|
299
|
+
*/
|
|
300
|
+
async function classifyGuarded(
|
|
301
|
+
classifier: Classifier,
|
|
302
|
+
prompt: string,
|
|
303
|
+
config: RouterConfig,
|
|
304
|
+
ctx: ExtensionContext,
|
|
305
|
+
): Promise<ClassificationResult> {
|
|
306
|
+
const budgetMs = config.backend === "laya" ? config.laya.timeoutMs : config.jev.timeoutMs;
|
|
307
|
+
const capabilities = detectHost(ctx);
|
|
308
|
+
const controller = new AbortController();
|
|
309
|
+
// Timers must be host-managed where possible: omp's `ctx.setTimeout` is
|
|
310
|
+
// unref'd, contains its callback's throws, and is cleared on session shutdown,
|
|
311
|
+
// which a raw timer would outlive. `startTimer` uses the managed surface when
|
|
312
|
+
// the host has one and a raw timer otherwise.
|
|
313
|
+
const softTimer = startTimer(capabilities, () => controller.abort(), budgetMs);
|
|
314
|
+
|
|
315
|
+
let releaseTimers = (): void => {
|
|
316
|
+
softTimer.cancel();
|
|
317
|
+
};
|
|
318
|
+
const hardGuard = new Promise<never>((_resolve, reject) => {
|
|
319
|
+
const hardTimer = startTimer(
|
|
320
|
+
capabilities,
|
|
321
|
+
() => {
|
|
322
|
+
controller.abort();
|
|
323
|
+
reject(
|
|
324
|
+
new ClassifierError(classifier.name, "timeout", `classification exceeded ${budgetMs}ms hard guard`),
|
|
325
|
+
);
|
|
326
|
+
},
|
|
327
|
+
budgetMs + HARD_GUARD_SLACK_MS,
|
|
328
|
+
);
|
|
329
|
+
releaseTimers = () => {
|
|
330
|
+
softTimer.cancel();
|
|
331
|
+
hardTimer.cancel();
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
// The guard must never surface as an unhandled rejection once the race settles.
|
|
335
|
+
hardGuard.catch(() => undefined);
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
return await Promise.race([classifier.classify(prompt, config.routing.questions, { signal: controller.signal }), hardGuard]);
|
|
339
|
+
} finally {
|
|
340
|
+
releaseTimers();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Event handlers
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
async function onBeforeAgentStart(
|
|
349
|
+
pi: ExtensionAPI,
|
|
350
|
+
event: { prompt: string },
|
|
351
|
+
ctx: ExtensionContext,
|
|
352
|
+
): Promise<void> {
|
|
353
|
+
try {
|
|
354
|
+
const state = ensureSession(ctx, pi);
|
|
355
|
+
const prompt = typeof event.prompt === "string" ? event.prompt : "";
|
|
356
|
+
const trimmed = prompt.trim();
|
|
357
|
+
|
|
358
|
+
if (!state.config.enabled) {
|
|
359
|
+
state.skipped += 1;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const subagent = isSubagentSession(ctx);
|
|
364
|
+
if (state.config.applyTo === "main" && subagent) {
|
|
365
|
+
state.skipped += 1;
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (state.config.applyTo === "subagents" && !subagent) {
|
|
369
|
+
state.skipped += 1;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Empty prompts and slash commands are not work to classify.
|
|
374
|
+
if (trimmed === "" || trimmed.startsWith("/")) {
|
|
375
|
+
state.skipped += 1;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const classifier = getClassifier(state.config, state.sessionId, pi);
|
|
380
|
+
if (classifier === null) {
|
|
381
|
+
state.skipped += 1;
|
|
382
|
+
notify(ctx, state, `backend ${state.config.backend} unavailable; using session model`, "warning");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const started = Date.now();
|
|
387
|
+
|
|
388
|
+
let answers: Record<string, Answer>;
|
|
389
|
+
try {
|
|
390
|
+
const result = await classifyGuarded(classifier, prompt, state.config, ctx);
|
|
391
|
+
answers = result.answers;
|
|
392
|
+
} catch (error) {
|
|
393
|
+
state.failed += 1;
|
|
394
|
+
const code = error instanceof ClassifierError ? error.code : "unexpected";
|
|
395
|
+
log(pi, "warn", "class-router: classification failed", {
|
|
396
|
+
backend: state.config.backend,
|
|
397
|
+
code,
|
|
398
|
+
error: error instanceof Error ? error.message : String(error),
|
|
399
|
+
});
|
|
400
|
+
if (error instanceof ClassifierError) {
|
|
401
|
+
notify(ctx, state, `classification failed (${error.code}); using session model`, "warning");
|
|
402
|
+
} else {
|
|
403
|
+
notify(ctx, state, `classification failed (unexpected); using session model`, "error");
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const breaker = runtime.breaker;
|
|
409
|
+
const decision = decide(state.config, answers, {
|
|
410
|
+
isAvailable: breaker === null ? () => true : (spec) => breaker.allow(spec),
|
|
411
|
+
});
|
|
412
|
+
state.lastDecision = decision;
|
|
413
|
+
|
|
414
|
+
await applyDecision(pi, ctx, state, decision);
|
|
415
|
+
|
|
416
|
+
const latencyMs = Date.now() - started;
|
|
417
|
+
const record: RouteRecord = {
|
|
418
|
+
category: decision.category,
|
|
419
|
+
confidence: decision.confidence,
|
|
420
|
+
spec: decision.spec,
|
|
421
|
+
apply: decision.apply,
|
|
422
|
+
reason: decision.reason,
|
|
423
|
+
backend: state.config.backend,
|
|
424
|
+
latencyMs,
|
|
425
|
+
at: Date.now(),
|
|
426
|
+
};
|
|
427
|
+
state.lastRecord = record;
|
|
428
|
+
state.history.push(record);
|
|
429
|
+
if (state.history.length > HISTORY_LIMIT) state.history.splice(0, state.history.length - HISTORY_LIMIT);
|
|
430
|
+
pi.appendEntry("class-router.decision", {
|
|
431
|
+
category: record.category,
|
|
432
|
+
confidence: record.confidence,
|
|
433
|
+
spec: record.spec,
|
|
434
|
+
apply: record.apply,
|
|
435
|
+
reason: record.reason,
|
|
436
|
+
backend: record.backend,
|
|
437
|
+
latencyMs: record.latencyMs,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Counted only once a decision actually exists: a failed classification is a
|
|
441
|
+
// failure, not a routed prompt.
|
|
442
|
+
state.routed += 1;
|
|
443
|
+
notify(ctx, state, `${decision.detail} [${latencyMs}ms via ${state.config.backend}]`);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
// Nothing below is allowed to break a turn.
|
|
446
|
+
log(pi, "error", "class-router: routing hook failed", {
|
|
447
|
+
error: error instanceof Error ? error.message : String(error),
|
|
448
|
+
});
|
|
449
|
+
try {
|
|
450
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} routing error; using session model`, "error");
|
|
451
|
+
} catch {
|
|
452
|
+
// UI unavailable in this host mode.
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function applyDecision(
|
|
458
|
+
pi: ExtensionAPI,
|
|
459
|
+
ctx: ExtensionContext,
|
|
460
|
+
state: SessionState,
|
|
461
|
+
decision: Decision,
|
|
462
|
+
): Promise<void> {
|
|
463
|
+
if (state.config.dryRun || decision.apply === null) return;
|
|
464
|
+
|
|
465
|
+
const capabilities = detectHost(ctx);
|
|
466
|
+
|
|
467
|
+
// Unannotated `let`: the resolved model type is not exported by the package.
|
|
468
|
+
let model;
|
|
469
|
+
try {
|
|
470
|
+
model = resolveModel(capabilities, ctx, decision.apply);
|
|
471
|
+
} catch (error) {
|
|
472
|
+
log(pi, "warn", "class-router: model resolution failed", {
|
|
473
|
+
spec: decision.apply,
|
|
474
|
+
error: error instanceof Error ? error.message : String(error),
|
|
475
|
+
});
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (model === undefined) {
|
|
480
|
+
log(pi, "warn", "class-router: model spec did not resolve", { spec: decision.apply });
|
|
481
|
+
notify(ctx, state, `spec ${decision.apply} did not resolve; keeping session model`, "warning");
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const currentId = currentModel(capabilities, ctx)?.id;
|
|
486
|
+
if (currentId === model.id) {
|
|
487
|
+
// Already the routed model: treat the decision as applied, but do not re-set it.
|
|
488
|
+
state.appliedSpec = decision.apply;
|
|
489
|
+
state.appliedModelId = model.id;
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Prefer the host's own auth verdict when it has one (upstream pi exposes
|
|
494
|
+
// `modelRegistry.hasConfiguredAuth`); otherwise rely on `setModel`'s result.
|
|
495
|
+
if (hasConfiguredAuth(capabilities, ctx, model) === false) {
|
|
496
|
+
if (!state.authNotified) {
|
|
497
|
+
state.authNotified = true;
|
|
498
|
+
notify(ctx, state, `no auth for ${decision.apply}; keeping session model`, "warning");
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
let changed: boolean;
|
|
504
|
+
try {
|
|
505
|
+
changed = await applyModel(pi, model);
|
|
506
|
+
} catch (error) {
|
|
507
|
+
log(pi, "warn", "class-router: setModel failed", {
|
|
508
|
+
spec: decision.apply,
|
|
509
|
+
error: error instanceof Error ? error.message : String(error),
|
|
510
|
+
});
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!changed) {
|
|
515
|
+
// No auth for this model is a configuration problem, not a model failure.
|
|
516
|
+
if (!state.authNotified) {
|
|
517
|
+
state.authNotified = true;
|
|
518
|
+
notify(ctx, state, `no auth for ${decision.apply}; keeping session model`, "warning");
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
state.appliedSpec = decision.apply;
|
|
524
|
+
state.appliedModelId = model.id;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** A run that ended in an error or abort, read off the trailing assistant message. */
|
|
528
|
+
function runFailed(messages: readonly unknown[]): boolean {
|
|
529
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
530
|
+
const message = messages[i] as { role?: unknown; stopReason?: unknown; errorMessage?: unknown } | undefined;
|
|
531
|
+
if (message === undefined || message.role !== "assistant") continue;
|
|
532
|
+
if (message.stopReason === "error" || message.stopReason === "aborted") return true;
|
|
533
|
+
if (typeof message.errorMessage === "string" && message.errorMessage !== "") return true;
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function onAgentEnd(
|
|
540
|
+
event: { messages: readonly unknown[]; willContinue?: boolean },
|
|
541
|
+
ctx: ExtensionContext,
|
|
542
|
+
): void {
|
|
543
|
+
try {
|
|
544
|
+
// An automatic continuation is not a settle: leave the breaker untouched.
|
|
545
|
+
if (event.willContinue === true) return;
|
|
546
|
+
|
|
547
|
+
const state = sessions.get(sessionKey(ctx));
|
|
548
|
+
if (state === undefined || state.appliedSpec === null) return;
|
|
549
|
+
|
|
550
|
+
// Only attribute an outcome while our model is still the session model; a
|
|
551
|
+
// manual `/model` switch must never be blamed on the router. The applied
|
|
552
|
+
// spec itself survives a model change, so a later turn can still use it.
|
|
553
|
+
const current = currentModel(detectHost(ctx), ctx);
|
|
554
|
+
if (current === undefined || current.id !== state.appliedModelId) {
|
|
555
|
+
state.pendingFailure = null;
|
|
556
|
+
state.failureRecorded = false;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const breaker = runtime.breaker;
|
|
561
|
+
const failed = state.pendingFailure !== null || runFailed(event.messages);
|
|
562
|
+
|
|
563
|
+
if (failed) {
|
|
564
|
+
if (!state.failureRecorded && breaker !== null) breaker.recordFailure(state.appliedSpec);
|
|
565
|
+
} else if (breaker !== null) {
|
|
566
|
+
breaker.recordSuccess(state.appliedSpec);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
state.pendingFailure = null;
|
|
570
|
+
state.failureRecorded = false;
|
|
571
|
+
} catch {
|
|
572
|
+
// Breaker bookkeeping never breaks a turn.
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function onAutoRetryStart(
|
|
577
|
+
pi: ExtensionAPI,
|
|
578
|
+
event: { errorMessage: string },
|
|
579
|
+
ctx: ExtensionContext,
|
|
580
|
+
): void {
|
|
581
|
+
try {
|
|
582
|
+
const state = sessions.get(sessionKey(ctx));
|
|
583
|
+
if (state === undefined || state.appliedSpec === null) return;
|
|
584
|
+
|
|
585
|
+
// Read-only guard: the applied spec must survive a model change, so a later
|
|
586
|
+
// routing attempt can still select (and attribute) it.
|
|
587
|
+
const current = currentModel(detectHost(ctx), ctx);
|
|
588
|
+
if (current === undefined || current.id !== state.appliedModelId) return;
|
|
589
|
+
|
|
590
|
+
const message = typeof event.errorMessage === "string" ? event.errorMessage : "";
|
|
591
|
+
state.pendingFailure = { reason: classifyFailure(message), message };
|
|
592
|
+
log(pi, "debug", "class-router: breaker failure", {
|
|
593
|
+
spec: state.appliedSpec,
|
|
594
|
+
reason: state.pendingFailure.reason,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
if (!state.failureRecorded && runtime.breaker !== null) {
|
|
598
|
+
runtime.breaker.recordFailure(state.appliedSpec);
|
|
599
|
+
state.failureRecorded = true;
|
|
600
|
+
}
|
|
601
|
+
} catch {
|
|
602
|
+
// Never break a turn from breaker bookkeeping.
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function onAutoRetryEnd(
|
|
607
|
+
event: { success: boolean },
|
|
608
|
+
ctx: ExtensionContext,
|
|
609
|
+
): void {
|
|
610
|
+
try {
|
|
611
|
+
if (!event.success) return;
|
|
612
|
+
|
|
613
|
+
const state = sessions.get(sessionKey(ctx));
|
|
614
|
+
if (state === undefined || state.appliedSpec === null) return;
|
|
615
|
+
|
|
616
|
+
const current = currentModel(detectHost(ctx), ctx);
|
|
617
|
+
if (current === undefined || current.id !== state.appliedModelId) return;
|
|
618
|
+
|
|
619
|
+
if (runtime.breaker !== null) runtime.breaker.recordSuccess(state.appliedSpec);
|
|
620
|
+
state.pendingFailure = null;
|
|
621
|
+
state.failureRecorded = false;
|
|
622
|
+
} catch {
|
|
623
|
+
// Never break a turn from breaker bookkeeping.
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function onSessionShutdown(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
628
|
+
const sessionId = sessionKey(ctx);
|
|
629
|
+
sessions.delete(sessionId);
|
|
630
|
+
|
|
631
|
+
if (runtime.classifierOwner === sessionId && runtime.classifier !== null) {
|
|
632
|
+
void runtime.classifier.dispose().catch(() => undefined);
|
|
633
|
+
runtime.classifier = null;
|
|
634
|
+
runtime.classifierKey = null;
|
|
635
|
+
runtime.classifierOwner = null;
|
|
636
|
+
log(pi, "debug", "class-router: classifier disposed");
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
// Command
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
|
|
644
|
+
function commandStatus(ctx: ExtensionContext, state: SessionState): void {
|
|
645
|
+
const config = state.config;
|
|
646
|
+
ctx.ui.notify(
|
|
647
|
+
`${NOTIFY_PREFIX} ${config.enabled ? "enabled" : "disabled"}${config.dryRun ? " (dry run)" : ""} | backend ${config.backend} | applyTo ${config.applyTo}`,
|
|
648
|
+
);
|
|
649
|
+
const source = state.source === null ? "none (built-in defaults)" : `${state.source.path} (${state.source.scope})`;
|
|
650
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} config source: ${oneLine(source)}`);
|
|
651
|
+
const breaker = runtime.breaker;
|
|
652
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} breaker: ${breaker === null ? "unconfigured" : formatBreaker(breaker.snapshot())}`);
|
|
653
|
+
const last = state.lastRecord;
|
|
654
|
+
if (last === null) {
|
|
655
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} last decision: none`);
|
|
656
|
+
} else {
|
|
657
|
+
ctx.ui.notify(
|
|
658
|
+
`${NOTIFY_PREFIX} last decision: ${last.category ?? "none"} -> ${last.apply ?? "unchanged"} (${last.reason}, ${last.confidence.toFixed(2)}, ${last.latencyMs}ms)`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
ctx.ui.notify(
|
|
662
|
+
`${NOTIFY_PREFIX} session: ${state.routed} routed, ${state.skipped} skipped, ${state.failed} failed`,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function commandExplain(ctx: ExtensionContext, state: SessionState): void {
|
|
667
|
+
const decision = state.lastDecision;
|
|
668
|
+
if (decision === null) {
|
|
669
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} no decision yet`);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const lines = [
|
|
673
|
+
`category: ${decision.category ?? "none"}`,
|
|
674
|
+
`confidence: ${decision.confidence.toFixed(2)}`,
|
|
675
|
+
`spec: ${decision.spec ?? "none"}`,
|
|
676
|
+
`chain: ${decision.chain.length === 0 ? "none" : decision.chain.join(" -> ")}`,
|
|
677
|
+
`apply: ${decision.apply ?? "unchanged"}`,
|
|
678
|
+
`reason: ${decision.reason}`,
|
|
679
|
+
`detail: ${decision.detail}`,
|
|
680
|
+
`backend: ${state.lastRecord?.backend ?? state.config.backend}`,
|
|
681
|
+
];
|
|
682
|
+
for (const line of lines) ctx.ui.notify(`${NOTIFY_PREFIX} ${oneLine(line)}`);
|
|
683
|
+
|
|
684
|
+
const extra = Object.entries(decision.extra);
|
|
685
|
+
if (extra.length === 0) {
|
|
686
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} other answers: none`);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
for (const [id, answer] of extra) ctx.ui.notify(`${NOTIFY_PREFIX} answer ${id}: ${formatAnswer(answer)}`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function registerCommand(pi: ExtensionAPI): void {
|
|
693
|
+
pi.registerCommand("class-router", {
|
|
694
|
+
description: "Inspect and control classifier-driven model routing",
|
|
695
|
+
getArgumentCompletions: (argumentPrefix: string) => {
|
|
696
|
+
const prefix = argumentPrefix.trim();
|
|
697
|
+
const matches = SUBCOMMANDS.filter((name) => name.startsWith(prefix));
|
|
698
|
+
if (matches.length === 0) return null;
|
|
699
|
+
return matches.map((name) => ({ value: name, label: name }));
|
|
700
|
+
},
|
|
701
|
+
handler: async (args: string, ctx) => {
|
|
702
|
+
try {
|
|
703
|
+
const state = ensureSession(ctx, pi);
|
|
704
|
+
const subcommand = (args.trim().split(/\s+/)[0] ?? "") || "status";
|
|
705
|
+
|
|
706
|
+
switch (subcommand) {
|
|
707
|
+
case "status":
|
|
708
|
+
commandStatus(ctx, state);
|
|
709
|
+
return;
|
|
710
|
+
case "on":
|
|
711
|
+
state.config.enabled = true;
|
|
712
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} routing enabled`);
|
|
713
|
+
return;
|
|
714
|
+
case "off":
|
|
715
|
+
state.config.enabled = false;
|
|
716
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} routing disabled; the session model is left alone`);
|
|
717
|
+
return;
|
|
718
|
+
case "reset": {
|
|
719
|
+
getBreaker(state.config, pi).reset();
|
|
720
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} circuit breaker reset`);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
case "explain":
|
|
724
|
+
commandExplain(ctx, state);
|
|
725
|
+
return;
|
|
726
|
+
default:
|
|
727
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} unknown subcommand ${subcommand}; valid: ${SUBCOMMANDS.join(", ")}`);
|
|
728
|
+
}
|
|
729
|
+
} catch (error) {
|
|
730
|
+
log(pi, "error", "class-router: command failed", {
|
|
731
|
+
error: error instanceof Error ? error.message : String(error),
|
|
732
|
+
});
|
|
733
|
+
ctx.ui.notify(`${NOTIFY_PREFIX} command failed`, "error");
|
|
734
|
+
}
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
// Extension factory
|
|
741
|
+
// ---------------------------------------------------------------------------
|
|
742
|
+
|
|
743
|
+
export default function classRouter(pi: ExtensionAPI): void {
|
|
744
|
+
// The extension label is omp-only (`setLabel(name)` there, vs a two-argument
|
|
745
|
+
// entry label upstream) and purely cosmetic, so it is not set.
|
|
746
|
+
|
|
747
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
748
|
+
try {
|
|
749
|
+
const state = ensureSession(ctx, pi);
|
|
750
|
+
// Loading Laya's checkpoints takes far longer than a turn's budget, so it
|
|
751
|
+
// starts here and never blocks the session. Jev's warmup is a no-op.
|
|
752
|
+
const classifier = getClassifier(state.config, state.sessionId, pi);
|
|
753
|
+
if (classifier?.warmup !== undefined) {
|
|
754
|
+
void classifier.warmup().catch((error: unknown) => {
|
|
755
|
+
log(pi, "warn", "class-router: classifier warmup failed", {
|
|
756
|
+
backend: state.config.backend,
|
|
757
|
+
error: error instanceof Error ? error.message : String(error),
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
} catch (error) {
|
|
762
|
+
log(pi, "error", "class-router: session start failed", {
|
|
763
|
+
error: error instanceof Error ? error.message : String(error),
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
769
|
+
try {
|
|
770
|
+
onSessionShutdown(pi, ctx);
|
|
771
|
+
} catch (error) {
|
|
772
|
+
log(pi, "warn", "class-router: shutdown failed", {
|
|
773
|
+
error: error instanceof Error ? error.message : String(error),
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
pi.on("before_agent_start", (event, ctx) => onBeforeAgentStart(pi, event, ctx));
|
|
779
|
+
pi.on("agent_end", (event, ctx) => onAgentEnd(event, ctx));
|
|
780
|
+
// The retry events are omp-only in the extension event union. Both hosts
|
|
781
|
+
// accept arbitrary event names today, so registration is attempted rather
|
|
782
|
+
// than gated; `registerEvent` reports refusal instead of throwing, which keeps
|
|
783
|
+
// extension load safe on a host that validates names. Payload shapes below are
|
|
784
|
+
// the host's event contracts, narrowed from the handler's `unknown`.
|
|
785
|
+
registerEvent(pi, "auto_retry_start", (payload, ctx) =>
|
|
786
|
+
onAutoRetryStart(pi, payload as { errorMessage: string }, ctx as ExtensionContext),
|
|
787
|
+
);
|
|
788
|
+
registerEvent(pi, "auto_retry_end", (payload, ctx) =>
|
|
789
|
+
onAutoRetryEnd(payload as { success: boolean }, ctx as ExtensionContext),
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
registerCommand(pi);
|
|
793
|
+
}
|