dsh-router-laya 2.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/index.js ADDED
@@ -0,0 +1,870 @@
1
+ /**
2
+ * Router plugin for the routing experiment: the *judge* half.
3
+ *
4
+ * Two layers live here, and they are the two the handoff calls layer 1:
5
+ *
6
+ * 1. `ROUTEEXP_ARM='provider/model:effort'` -- a fixed arm. Unchanged, and still the whole control
7
+ * surface for the pilot: unset means this plugin does nothing at all, which is why it is safe to
8
+ * leave installed anywhere.
9
+ * 2. `ROUTEEXP_ARM='judge'` -- ask a cheap model to rate the first task text of a session once
10
+ * (easy | medium | hard), cache that verdict per session, and map it through `ROUTE_TABLE` to a
11
+ * route for every request in that session.
12
+ *
13
+ * A comma-separated list still cycles routes per request, which is what the cache question needed.
14
+ * The effort level is whatever the target route accepts -- this file does not own that vocabulary.
15
+ *
16
+ * The judge is a *level*, never a route: it answers one word, so a routing table can be re-tuned
17
+ * without re-prompting it. It is one cheap call per session and `ROUTE_TABLE.fallback` covers every
18
+ * failure path, so a judge that is slow, refused, or nonsensical degrades to the fallback column
19
+ * instead of breaking the session.
20
+ *
21
+ * Deferring to an explicit route (see ROUTEEXP_RESPECT_EXPLICIT below) exists because the `subagent`
22
+ * tool can carry its own provider/model/effort. Without it this plugin would overwrite the very
23
+ * choice layer 2 just made.
24
+ *
25
+ * Logging goes to stderr only: a headless run prints the session's final answer on stdout, and a
26
+ * plugin writing there would corrupt it.
27
+ */
28
+
29
+ export const name = 'router-laya';
30
+
31
+ // No `llm` inject needed: the judge calls a local Laya HTTP router instead of an LLM service.
32
+ // A lazy import path keeps the file loadable by `file:///` URL from a checkout with no node_modules.
33
+ export const inject = [];
34
+
35
+ // Node builtins only, so the `file:///` row from a checkout still resolves (see `inject`). These are
36
+ // for the judge service's auto-start (see SERVICE_* below), not for routing.
37
+ import { closeSync, existsSync as fsExists, openSync } from 'node:fs';
38
+ import { spawn } from 'node:child_process';
39
+ import { tmpdir } from 'node:os';
40
+ import { dirname as pathDirname } from 'node:path';
41
+ import { fileURLToPath } from 'node:url';
42
+
43
+ /** This file's directory -- the starting point for the dev-checkout search in `serviceLaunchSpec`. */
44
+ const hereDir = pathDirname(fileURLToPath(import.meta.url));
45
+
46
+ /**
47
+ * Where a judge we spawned writes its own diagnostics.
48
+ *
49
+ * Measured the hard way (2026-09-25): with `stdio: 'ignore'` a spawn that dies -- here, the sandbox
50
+ * refusing the port bind -- leaves *nothing* to look at, just a 90s wait and a "never answered" line.
51
+ * The child's stderr is the only place its real reason exists, so keep it. Constant rather than
52
+ * per-port, exactly like the handoff's `start_router.ps1` (`$env:TEMP/laya_router.err.log`).
53
+ */
54
+ const SERVICE_LOG_PATH = `${tmpdir()}/laya-router-service.log`;
55
+
56
+ const DEFAULT_PROVIDER = 'deepseek-official';
57
+
58
+ /** Difficulty levels the judge may answer with, easiest first. */
59
+ export const LEVELS = ['easy', 'medium', 'hard'];
60
+
61
+ /**
62
+ * Judgment -> route. The single place a policy change belongs.
63
+ *
64
+ * Two steps, not three: the pilot found no accuracy difference between *capable* routes on anything
65
+ * it could measure, so a middle tier would be a claim without evidence. `medium` repeats `fallback`
66
+ * deliberately.
67
+ *
68
+ * `hard` is Qwen `qwen3.8-flash` at `xhigh`, not a DeepSeek pro route: the flash-class model under
69
+ * test here already outperforms the pro tier, so escalating up the same vendor buys nothing. Its
70
+ * effort vocabulary is `low|medium|xhigh` -- there is no `max`, which is exactly why the levels are
71
+ * no longer hardcoded in `parseArm`.
72
+ */
73
+ export const ROUTE_TABLE = {
74
+ easy: { provider: DEFAULT_PROVIDER, model: 'deepseek-flash', effort: 'low' },
75
+ medium: { provider: DEFAULT_PROVIDER, model: 'deepseek-flash', effort: 'low' },
76
+ hard: { provider: 'qwen-token-plan-cn', model: 'qwen3.8-flash', effort: 'xhigh' },
77
+ fallback: { provider: DEFAULT_PROVIDER, model: 'deepseek-flash', effort: 'low' },
78
+ };
79
+
80
+ /** The judge's route is now the local Laya HTTP router, not an LLM call. */
81
+ const JUDGE_TIMEOUT_MS = 20000;
82
+ /** Task text is truncated before it reaches the judge. */
83
+ const JUDGE_MAX_CHARS = 4000;
84
+
85
+ /**
86
+ * The runtime mode switch (2026-09-25).
87
+ *
88
+ * Until now the mode was a *mount-time constant*: `ROUTEEXP_ARM` (an environment variable, so it only
89
+ * exists when dsh was launched from a shell that exported it) or the row's `cfg.auto`. Measured live:
90
+ * a row driven by the env var silently routed nothing after a GUI restart, because the variable was
91
+ * never in the GUI's environment. There was no way to switch back to manual without editing
92
+ * `cordis.patch.yml` and restarting.
93
+ *
94
+ * So the mode lives in a settings namespace instead, and is read **per request** rather than once at
95
+ * apply(): flipping the switch takes effect on the next model call, with no restart. A composition
96
+ * with no settings plane still works -- the read falls back to the mount-time value (`cfg`, then
97
+ * `ROUTEEXP_ARM`), which is exactly the old behaviour.
98
+ *
99
+ * The two layers answer different questions and must not be confused: the *mode* selects whether auto
100
+ * routing runs at all; the *tier table* selects where a judged tier lands. Only the first is a user
101
+ * preference, so only the first is in settings.
102
+ */
103
+ export const MODE_NS = 'router-laya';
104
+ /** `auto` routes every turn; `manual` leaves every request exactly as its owner configured it. */
105
+ export const MODES = ['manual', 'auto'];
106
+
107
+ /**
108
+ * The mode in force for the next request: the settings value when available, else the mount-time
109
+ * fallback. Never throws -- a settings plane that rejects the read must not take routing down with it
110
+ * (the same fail-safe posture as every other path in this file).
111
+ */
112
+ export function currentMode(ctx, fallback) {
113
+ try {
114
+ const settings = ctx !== undefined && ctx !== null ? ctx.get('settings') : undefined;
115
+ if (settings === undefined || settings === null) return fallback;
116
+ if (typeof settings.get !== 'function') return fallback;
117
+ const section = settings.get(MODE_NS);
118
+ const mode = section !== undefined && section !== null ? section.mode : undefined;
119
+ return MODES.includes(mode) ? mode : fallback;
120
+ } catch {
121
+ return fallback;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Publish the mode as a settings namespace so a client can switch it at runtime.
127
+ *
128
+ * Returns the `settings` service, or null when this composition serves no settings plane. Deliberately
129
+ * its own try/catch and its own lazy import: schemastery ships as a peer the profile provides, so a
130
+ * deployment without it loses the *switch*, never the routing -- `currentMode` then falls back to the
131
+ * mount-time value, which is the old behaviour.
132
+ *
133
+ * `onChange` is what makes the switch live: `installSection` hands back the newly composed section on
134
+ * every commit, and the auto path reads that value per request (see the `agent/request` listener).
135
+ */
136
+ async function installModeSetting(ctx, fallback, sink) {
137
+ try {
138
+ const settings = ctx.get('settings');
139
+ if (settings === undefined || settings === null) return null;
140
+ if (typeof settings.installSection !== 'function') return null;
141
+ const z = (await import('@deepseek-ai/schemastery')).default;
142
+ const schema = z.object({
143
+ mode: z.union(MODES).default(fallback),
144
+ });
145
+ settings.installSection(ctx, MODE_NS, schema, { mode: fallback }, {
146
+ setSource: (source) => { sink.mode = source; },
147
+ onChange: () => {},
148
+ });
149
+ return settings;
150
+ } catch (error) {
151
+ log(`mode setting unavailable (${error.message}) -- the switch is off, routing is unchanged`);
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Serve the tier chip's mode switch over the web carrier, same-origin.
158
+ *
159
+ * The chip is a *static* client bundle, so it has no `host.call` (that is a dynamic-Package private RPC)
160
+ * and it cannot reach `settings` either. A same-origin route is the one channel both sides already have:
161
+ * the browser fetches this origin, so no CORS is involved, and the write happens here in the host realm
162
+ * where the settings service accepts an ordinary object literal.
163
+ *
164
+ * GET -> `{mode}` so the chip can render the current mode
165
+ * POST -> `{mode}` to switch, body `{"mode":"auto"|"manual"}`
166
+ *
167
+ * The disposer goes through `ctx.effect`: a `webServer.register` disposer nobody keeps leaks the path for
168
+ * the life of the process, and a later registration then fails with "duplicate exact route".
169
+ */
170
+ function installModeRoute(ctx, sink) {
171
+ // `ctx.inject`, NOT a bare `ctx.get`: this row mounts during boot, and `webServer` may not have
172
+ // registered yet at apply() time. A `ctx.get` that misses returns undefined, the guard below returns,
173
+ // and the route is simply never served -- no error, no log, a 404 forever. `inject` runs the callback
174
+ // when the service arrives instead. The official provider packages use the same shape.
175
+ ctx.inject(['webServer'], (scoped) => {
176
+ const webServer = scoped.get('webServer');
177
+ if (webServer === undefined || webServer === null) return;
178
+ scoped.effect(() => webServer.register({
179
+ kind: 'exact',
180
+ path: '/router-laya/mode',
181
+ handler: (req, res) => {
182
+ const send = (code, body) => {
183
+ const text = JSON.stringify(body);
184
+ res.statusCode = code;
185
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
186
+ res.setHeader('Content-Length', String(Buffer.byteLength(text)));
187
+ res.end(text);
188
+ };
189
+ if (req.method === 'GET') {
190
+ send(200, { mode: currentMode(ctx, sink.mode) });
191
+ return;
192
+ }
193
+ if (req.method !== 'POST') {
194
+ send(405, { error: 'method not allowed' });
195
+ return;
196
+ }
197
+ let raw = '';
198
+ req.on('data', (chunk) => { raw += chunk; if (raw.length > 4096) req.destroy(); });
199
+ req.on('end', () => {
200
+ Promise.resolve().then(async () => {
201
+ let wanted = null;
202
+ try {
203
+ wanted = JSON.parse(raw).mode;
204
+ } catch {
205
+ send(400, { error: 'body must be json' });
206
+ return;
207
+ }
208
+ if (!MODES.includes(wanted)) {
209
+ send(400, { error: `mode must be one of ${MODES.join('|')}` });
210
+ return;
211
+ }
212
+ const settings = ctx.get('settings');
213
+ if (settings === undefined || settings === null) {
214
+ send(503, { error: 'settings unavailable' });
215
+ return;
216
+ }
217
+ await settings.update(MODE_NS, { mode: wanted });
218
+ send(200, { mode: currentMode(ctx, sink.mode) });
219
+ }).catch((error) => {
220
+ send(500, { error: String(error && error.message ? error.message : error) });
221
+ });
222
+ });
223
+ },
224
+ }));
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Service auto-start (distribution, 2026-09-25).
230
+ *
231
+ * The other plugins in a profile are pure Node, so mounting the row IS starting them and there is
232
+ * nothing to keep alive. This one is a *pair*: the row is the thin in-process half, and the judge is a
233
+ * separate Python process holding an 807 MB checkpoint. Until now the user had to run
234
+ * `routing/start_router.ps1` by hand before every session, which is exactly the kind of step that
235
+ * silently does not happen -- measured live: the plugin was mounted and byte-identical to this source
236
+ * while `/health` refused connections, so every turn paid the full 20s timeout and fell back to low
237
+ * with nothing on screen to say why.
238
+ *
239
+ * So mounting the row also brings the service up, when it can find one. Deliberate properties:
240
+ *
241
+ * - OFF the request path. The spawn happens once at apply() and never delays or fails a turn; a
242
+ * service that is still loading just means the next turns take the existing fallback until it
243
+ * answers. `fail-safe` is unchanged.
244
+ * - Never fatal. A missing python, a missing script, a dead spawn: log a line and return. The
245
+ * session must not learn that the judge is unhappy.
246
+ * - Idempotent, and duplicated starts are already safe: `laya_router.py` binds 127.0.0.1:8765, so a
247
+ * second process dies on EADDRINUSE and exits. Two DSH instances therefore cannot both own it --
248
+ * the loser exits rather than fighting for the port.
249
+ * - Detached, stdio ignored: the judge must outlive nothing in particular and must not hold a pipe
250
+ * to the harness. It is a plain background process, killed the way any other one is.
251
+ *
252
+ * Discovery is ordered and silent: `cfg.servicePython`/`cfg.serviceScript` win, then a dev checkout
253
+ * (walking up from this file for the shipped venv and the `training/laya_router_finetuned` that only
254
+ * the repo has), and if neither resolves we do not guess -- we log and leave the pass-through alone.
255
+ * A packaged install is expected to point these at its own `service/` directory.
256
+ */
257
+ const SERVICE_HEALTH_TIMEOUT_MS = 700;
258
+ /** How long a cold start may take before we stop looking. CPU checkpoint load measured ~2s here. */
259
+ const SERVICE_START_TIMEOUT_MS = 90000;
260
+
261
+ /**
262
+ * Tier -> route for AUTO mode (plugin v2, docs/plugin-v2-plan.md §3.3).
263
+ *
264
+ * Deliberately a different table from ROUTE_TABLE: v2 is the product path (none -> tier via the
265
+ * fine-tuned 7-question judge), while ROUTE_TABLE serves the v1 judge experiment (hard -> qwen
266
+ * xhigh). The two stay separate so experiment baselines remain comparable. `max` is legal on
267
+ * deepseek-official -- the adapter's `resolveThinking` accepts off|low|high|max (verified in the
268
+ * dsh-llm-deepseek source). A preset re-points rows via its own `tiers` config, mirroring
269
+ * `cfg.routes`.
270
+ */
271
+ export const TIER_TABLE = {
272
+ low: { provider: DEFAULT_PROVIDER, model: 'deepseek-flash', effort: 'low' },
273
+ high: { provider: DEFAULT_PROVIDER, model: 'deepseek-flash', effort: 'high' },
274
+ max: { provider: DEFAULT_PROVIDER, model: 'deepseek-flash', effort: 'max' },
275
+ fallback: { provider: DEFAULT_PROVIDER, model: 'deepseek-flash', effort: 'low' },
276
+ };
277
+
278
+ /**
279
+ * Parse one arm as `[provider/]model[:effort]`.
280
+ *
281
+ * Returns null for an empty spec, and throws on a structurally malformed one rather than running a
282
+ * wrong arm -- a typo in a *provider* or *model* would otherwise quietly produce a whole run of
283
+ * mislabelled results.
284
+ *
285
+ * The effort level is deliberately NOT validated against a list here. Effort is an adapter-owned
286
+ * vocabulary and every route spells it differently -- `deepseek-official` accepts `off|low|high|max`,
287
+ * `qwen-token-plan-cn` accepts `low|medium|xhigh` -- so a hardcoded list in this file rejects valid
288
+ * levels on every model it was not written for. An unusable level fails loudly at the adapter one
289
+ * step later (a QUOTA/INVALID code in the session log), which is the same fail-loud property without
290
+ * owning a vocabulary this plugin does not define.
291
+ */
292
+ export function parseArm(spec) {
293
+ if (spec === undefined || spec === null || String(spec).trim() === '') return null;
294
+ const text = String(spec).trim();
295
+ const colon = text.lastIndexOf(':');
296
+ const head = colon === -1 ? text : text.slice(0, colon);
297
+ const effort = colon === -1 ? undefined : text.slice(colon + 1);
298
+ if (effort !== undefined && effort === '') {
299
+ throw new Error(`ROUTEEXP_ARM "${text}" has a trailing colon with no effort`);
300
+ }
301
+ const slash = head.indexOf('/');
302
+ const provider = slash === -1 ? DEFAULT_PROVIDER : head.slice(0, slash);
303
+ const model = slash === -1 ? head : head.slice(slash + 1);
304
+ if (!provider || !model) throw new Error(`ROUTEEXP_ARM "${text}" has an empty provider or model`);
305
+ return { provider, model, effort };
306
+ }
307
+
308
+ /**
309
+ * Parse `ROUTEEXP_ARM` as a comma-separated arm schedule: null when unset, one entry for a constant
310
+ * arm, more for a per-request cycle. The literal `judge` is not an arm -- it selects the judge layer.
311
+ */
312
+ export function parseSchedule(spec) {
313
+ if (spec === undefined || spec === null || String(spec).trim() === '') return null;
314
+ const arms = String(spec).split(',').map((s) => parseArm(s));
315
+ if (arms.some((a) => a === null)) throw new Error(`ROUTEEXP_ARM "${spec}" has an empty arm`);
316
+ return arms;
317
+ }
318
+
319
+ /** The one place a mode literal replaces the env var. `auto` is checked before parseSchedule so a
320
+ * malformed arm elsewhere in the spec still throws loud (deepseek review F13). */
321
+ export function resolveSchedule() {
322
+ const spec = process.env.ROUTEEXP_ARM;
323
+ const text = spec === undefined ? '' : String(spec).trim().toLowerCase();
324
+ if (text === 'judge') return 'judge';
325
+ if (text === 'auto') return 'auto';
326
+ return parseSchedule(spec);
327
+ }
328
+
329
+ /**
330
+ * Map a judge answer onto a route. Anything unrecognised takes that table's fallback column.
331
+ *
332
+ * `table` defaults to the built-in one, which keeps the level -> route mapping a pure function the
333
+ * tests can drive, while a preset row can re-point it from its own config.
334
+ */
335
+ export function routeForLevel(level, table = ROUTE_TABLE) {
336
+ const key = typeof level === 'string' ? level.trim().toLowerCase() : '';
337
+ return table[key] || table.fallback;
338
+ }
339
+
340
+ /**
341
+ * Apply one route to a request config.
342
+ *
343
+ * `maxTokens` is dropped: it reaches this seam already defaulted by the *previous* route's adapter
344
+ * (flagged as an adapter default in the request header), so keeping it would pin one adapter's cap
345
+ * onto another model.
346
+ */
347
+ export function applyRoute(config, route) {
348
+ const out = { ...config, provider: route.provider, model: route.model };
349
+ delete out.maxTokens;
350
+ if (route.effort === undefined) delete out.reasoningEffort;
351
+ else out.reasoningEffort = route.effort;
352
+ return out;
353
+ }
354
+
355
+ /**
356
+ * Whether the mounted adapters can actually serve one route.
357
+ *
358
+ * A table entry naming a provider this profile never registered would replace the user's model with
359
+ * an unroutable one and fail the step, so the judge path checks the live registry first. Only the
360
+ * PROVIDER is checked, and deliberately not the effort: effort is adapter-owned vocabulary (see
361
+ * `parseArm`), and re-owning it here is exactly the bug that comment already records.
362
+ *
363
+ * Returns null when the route cannot be served, which leaves the request on the config its own owner
364
+ * chose. The cycling arm path is NOT guarded -- there a wrong arm has to stay loud.
365
+ */
366
+ export function usableRoute(ctx, route) {
367
+ // null and undefined both mean "no route to apply". A row config that nulls the fallback column
368
+ // (`routes: { fallback: }`) would otherwise reach `applyRoute` as undefined and throw on the step.
369
+ if (route === undefined || route === null) return null;
370
+ const llm = ctx.get('llm');
371
+ if (llm === undefined) return route;
372
+ let providers;
373
+ try {
374
+ providers = llm.listProviders();
375
+ } catch {
376
+ return route;
377
+ }
378
+ if (providers.some((p) => p.id === route.provider)) return route;
379
+ log(`route ${route.provider}/${route.model} names a provider this profile has not registered`
380
+ + ' -- leaving the request on its own config');
381
+ return null;
382
+ }
383
+
384
+ /**
385
+ * Whether an incoming config already names a route its owner chose.
386
+ *
387
+ * The loop seeds a request from the agent's own options, so a config that carries an explicit
388
+ * `reasoningEffort` is an agent that was told what to use -- a `subagent` call with
389
+ * `reasoning_effort`, for instance. Rewriting it would silently discard that choice.
390
+ */
391
+ export function carriesExplicitRoute(config) {
392
+ return config !== undefined && config !== null && config.reasoningEffort !== undefined;
393
+ }
394
+
395
+ /**
396
+ * Whether the explicit effort on this config is the route WE applied last turn (plugin v2.1).
397
+ *
398
+ * `carriesExplicitRoute` alone cannot tell a delegated choice from our own footprint: the route
399
+ * this plugin applies becomes the session's config, so from the second request on every turn
400
+ * looks "explicit" and auto mode locks itself to one routing per agent (live-verified 2026-09-25,
401
+ * two independent causes: the global `agent-default-model.reasoningEffort` seeds every request,
402
+ * and our own writes re-read as explicit). The session state knows what we served last -- if the
403
+ * config matches it, it is ours and MUST be re-routed (that is the whole point of auto); anything
404
+ * else (subagent delegation, a user-pinned effort) stays deferred.
405
+ */
406
+ export function isOurRoute(config, state, tiers = TIER_TABLE) {
407
+ if (config === undefined || config === null || config.reasoningEffort === undefined) return false;
408
+ if (state === undefined || state.tier === undefined) return false;
409
+ const ours = tiers[state.tier];
410
+ if (ours === undefined) return false;
411
+ return config.reasoningEffort === ours.effort && config.model === ours.model;
412
+ }
413
+
414
+ function routeLabel(route) {
415
+ return `${route.provider}/${route.model} effort=`
416
+ + `${route.effort === undefined ? 'adapter default' : route.effort}`;
417
+ }
418
+
419
+ function log(message) {
420
+ process.stderr.write(`[router-laya] ${message}\n`);
421
+ }
422
+
423
+ /** Flatten one message's text blocks; returns '' when it carries none. */
424
+ function textOf(message) {
425
+ return (message && message.content ? message.content : [])
426
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
427
+ .map((block) => block.text)
428
+ .join('\n')
429
+ .trim();
430
+ }
431
+
432
+ /**
433
+ * Ask the Laya router (HTTP) for one difficulty level.
434
+ *
435
+ * The Laya router runs as a separate Python process: `python routing/laya_router.py --http`.
436
+ * This keeps the plugin free of Python dependencies -- it just POSTs JSON.
437
+ * Every failure path returns null and the caller falls back.
438
+ */
439
+ export async function judgeDifficulty(ctx, taskText, sessionId, signal) {
440
+ if (taskText === null || taskText === '') return null;
441
+ const url = process.env.LAYA_ROUTER_URL || 'http://127.0.0.1:8765/judge';
442
+ const timeout = AbortSignal.timeout(JUDGE_TIMEOUT_MS);
443
+ if (signal !== undefined && typeof signal.addEventListener === 'function') {
444
+ signal.addEventListener('abort', () => timeout.abort?.(), { once: true });
445
+ }
446
+ try {
447
+ const res = await fetch(url, {
448
+ method: 'POST',
449
+ headers: { 'Content-Type': 'application/json' },
450
+ body: JSON.stringify({ task: taskText.slice(0, JUDGE_MAX_CHARS) }),
451
+ signal: timeout,
452
+ });
453
+ if (!res.ok) {
454
+ log(`Laya router HTTP ${res.status}`);
455
+ return null;
456
+ }
457
+ const j = await res.json();
458
+ if (j.error) {
459
+ log(`Laya router error: ${j.error}`);
460
+ return null;
461
+ }
462
+ // Map Laya's difficulty_level (0-3: trivial/easy/moderate/hard) -> LEVELS (easy/medium/hard)
463
+ const label = j.difficulty_label || '';
464
+ if (label === 'trivial' || label === 'easy') return 'easy';
465
+ if (label === 'moderate') return 'medium';
466
+ if (label === 'hard') return 'hard';
467
+ log(`Laya returned unknown level: ${label}`);
468
+ return null;
469
+ } catch (e) {
470
+ log(`Laya router call failed: ${e.message}`);
471
+ return null;
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Ask the Laya router (HTTP) for one tier (AUTO mode, plugin v2).
477
+ *
478
+ * Wire format (v2 defines it; v1's judgeDifficulty sends `{task}` only): the current task text
479
+ * plus the session's previous tier and task text, so the Python side runs the full product path
480
+ * -- dictionary intent, 7-question judge, rule engine with rule 0, regenerate detection, and the
481
+ * constraint algebra as the last gate. Every failure path returns null and the caller applies
482
+ * TIER_TABLE.fallback (deepseek review F2: "never break the session" means the fallback ROUTE
483
+ * here; a route the profile cannot serve is handled separately by `usableRoute`).
484
+ */
485
+ export async function judgeTier(taskText, prevTier, prevTask, sessionId, signal) {
486
+ if (taskText === null || taskText === '') return null;
487
+ const url = process.env.LAYA_ROUTER_URL || 'http://127.0.0.1:8765/judge';
488
+ const timeout = AbortSignal.timeout(JUDGE_TIMEOUT_MS);
489
+ if (signal !== undefined && typeof signal.addEventListener === 'function') {
490
+ signal.addEventListener('abort', () => timeout.abort?.(), { once: true });
491
+ }
492
+ try {
493
+ const res = await fetch(url, {
494
+ method: 'POST',
495
+ headers: { 'Content-Type': 'application/json' },
496
+ body: JSON.stringify({
497
+ task: taskText.slice(0, JUDGE_MAX_CHARS),
498
+ prev_tier: prevTier === undefined ? null : prevTier,
499
+ prev_task: prevTask === undefined ? null : prevTask,
500
+ session_id: sessionId === undefined ? null : sessionId,
501
+ }),
502
+ signal: timeout,
503
+ });
504
+ if (!res.ok) {
505
+ log(`Laya router HTTP ${res.status}`);
506
+ return null;
507
+ }
508
+ const j = await res.json();
509
+ if (j.error) {
510
+ log(`Laya router error: ${j.error}`);
511
+ return null;
512
+ }
513
+ if (typeof j.tier !== 'string' || j.tier === '') {
514
+ log(`Laya router returned no tier`);
515
+ return null;
516
+ }
517
+ return { tier: j.tier, triggered_by: j.triggered_by || '', labels: j.labels || null };
518
+ } catch (e) {
519
+ log(`Laya router call failed: ${e.message}`);
520
+ return null;
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Pure session-state transition for AUTO mode, exported for offline tests.
526
+ *
527
+ * `state` is the previous `{ tier, task, turn }` (undefined on turn 1); `judgment` is the
528
+ * judgeTier result or null. Turn went BACKWARDS (session reset / id reuse) -> the state is
529
+ * discarded (deepseek review F4: a stale prev_tier/prev_task must not leak into a fresh
530
+ * conversation). A failed judgment keeps the route policy's tier (the fallback we actually
531
+ * served) but always advances the stored task text -- the next turn's regenerate check compares
532
+ * against what the user last asked, judged or not.
533
+ */
534
+ export function nextSessionState(state, turn, taskText, judgment) {
535
+ const reused = state !== undefined && state.turn !== undefined
536
+ && turn !== undefined && turn < state.turn;
537
+ const base = reused ? undefined : state;
538
+ const tier = judgment !== null
539
+ ? judgment.tier
540
+ : (base !== undefined && base.tier !== undefined ? base.tier : 'low');
541
+ return { tier, task: taskText, turn };
542
+ }
543
+
544
+ /**
545
+ * The judge service's base URL -- the one place `LAYA_ROUTER_URL` is read for the health probe.
546
+ *
547
+ * `LAYA_ROUTER_URL` is the full endpoint (`.../judge`); `/health` is its sibling, so the tail is
548
+ * replaced rather than appended to whatever the user configured.
549
+ */
550
+ export function serviceBaseUrl(env = process.env) {
551
+ const raw = env.LAYA_ROUTER_URL || 'http://127.0.0.1:8765/judge';
552
+ return String(raw).replace(/\/judge\/?$/, '');
553
+ }
554
+
555
+ /**
556
+ * What a spawn of the judge service needs, or null when this install cannot launch one.
557
+ *
558
+ * Pure, so the tests drive it without touching a filesystem: `exists` and `platform` are injected.
559
+ * Explicit config always wins; the dev-checkout walk is only a convenience for running out of this
560
+ * repository, where the venv and the checkpoint live in known places.
561
+ */
562
+ export function serviceLaunchSpec(cfg, env, here, exists, platform) {
563
+ if (cfg.autoStart === false) return null;
564
+ const script = cfg.serviceScript || (env.LAYA_MODEL_SCRIPT);
565
+ const python = cfg.servicePython;
566
+ if (script !== undefined && python !== undefined) {
567
+ return exists(script) && exists(python) ? { python, script } : null;
568
+ }
569
+ // Dev checkout: this file sits at <repo>/routing/plugin/dsh-router-laya/index.js. The checkpoint
570
+ // under <repo>/training is the marker -- a packaged install does not ship it here.
571
+ let dir = here;
572
+ for (let depth = 0; depth < 6 && dir !== undefined; depth++) {
573
+ const repo = dir;
574
+ if (exists(`${repo}/training/laya_router_finetuned`)) {
575
+ const venv = platform === 'win32' ? '/.venv/Scripts/python.exe' : '/.venv/bin/python';
576
+ if (exists(repo + venv) && exists(`${repo}/routing/laya_router.py`)) {
577
+ return { python: repo + venv, script: `${repo}/routing/laya_router.py` };
578
+ }
579
+ break;
580
+ }
581
+ const up = repo.replace(/[\\/][^\\/]+$/, '');
582
+ dir = up === repo || up === '' ? undefined : up;
583
+ }
584
+ return null;
585
+ }
586
+
587
+ /** Resolve a URL without `URL.parse` throwing on a value a user typed. */
588
+ function resolveUrl(value) {
589
+ const parsed = new URL(value);
590
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
591
+ throw new Error(`unsupported protocol ${parsed.protocol}`);
592
+ }
593
+ return parsed;
594
+ }
595
+
596
+ /** Request one URL and return the parsed JSON body, or null for every failure and non-2xx. */
597
+ async function getJson(url, timeoutMs) {
598
+ let parsed;
599
+ try {
600
+ parsed = resolveUrl(url);
601
+ } catch {
602
+ return null;
603
+ }
604
+ const controller = new AbortController();
605
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
606
+ try {
607
+ const res = await fetch(parsed, { method: 'GET', signal: controller.signal });
608
+ return res.ok ? await res.json() : null;
609
+ } catch {
610
+ return null;
611
+ } finally {
612
+ clearTimeout(timer);
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Whether the judge is up *and* speaking the protocol this plugin's AUTO path needs.
618
+ *
619
+ * `protocol=finetuned` is the acceptance the handoff's step 5 uses (`GET /health`), and it is the
620
+ * difference between tier output and the base protocol's difficulty labels: a base-protocol service
621
+ * answers `/judge` with no `tier` field at all, which auto mode would silently treat as unreachable.
622
+ * So a healthy base service is correctly reported as NOT usable here rather than started twice.
623
+ */
624
+ export async function judgeServiceUp(base, timeoutMs = SERVICE_HEALTH_TIMEOUT_MS) {
625
+ const info = await getJson(`${base}/health`, timeoutMs);
626
+ return info !== null && info.protocol === 'finetuned';
627
+ }
628
+
629
+ /**
630
+ * Bring the judge up if it is down, then wait for it to answer. Never throws.
631
+ *
632
+ * Returns a short human-readable outcome for the log; the caller ignores it. Called asynchronously
633
+ * from `apply()` so DSH's boot is never waiting on an 807 MB checkpoint load.
634
+ */
635
+ export async function ensureJudgeService(cfg, env = process.env) {
636
+ const base = serviceBaseUrl(env);
637
+ if (await judgeServiceUp(base)) return `already up (${base})`;
638
+
639
+ let spec;
640
+ try {
641
+ spec = serviceLaunchSpec(cfg, env, hereDir, fsExists, process.platform);
642
+ } catch (e) {
643
+ return `could not resolve a service to launch: ${e.message}`;
644
+ }
645
+ if (spec === null) {
646
+ return `down and no service found to launch -- set config.servicePython/serviceScript `
647
+ + `to enable auto-start (see README)`;
648
+ }
649
+
650
+ let logFd;
651
+ try {
652
+ logFd = openSync(SERVICE_LOG_PATH, 'a');
653
+ } catch {
654
+ logFd = undefined; // an unwritable temp dir must not stop the start attempt
655
+ }
656
+ try {
657
+ const child = spawn(spec.python, [spec.script, '--http', '--port', String(new URL(base).port || 80)], {
658
+ detached: true,
659
+ stdio: ['ignore', 'ignore', logFd === undefined ? 'ignore' : logFd],
660
+ env: { ...env, PYTHONIOENCODING: 'utf-8' },
661
+ });
662
+ child.unref();
663
+ } catch (e) {
664
+ return `spawn failed: ${e.message}`;
665
+ } finally {
666
+ // The child holds its own duplicate; the parent must not pin the file descriptor.
667
+ if (logFd !== undefined) { try { closeSync(logFd); } catch { /* already gone */ } }
668
+ }
669
+
670
+ const deadline = Date.now() + SERVICE_START_TIMEOUT_MS;
671
+ while (Date.now() < deadline) {
672
+ await new Promise((resolve) => { setTimeout(resolve, 500); });
673
+ if (await judgeServiceUp(base)) return `started (${spec.python})`;
674
+ }
675
+ return `spawned but ${base}/health never answered in ${SERVICE_START_TIMEOUT_MS / 1000}s`
676
+ + ` -- see ${SERVICE_LOG_PATH}`;
677
+ }
678
+
679
+ export function apply(ctx, config) {
680
+ const cfg = config !== null && typeof config === 'object' ? config : {};
681
+ // The judge/auto mode is normally selected by `ROUTEEXP_ARM=judge|auto`, the experiment's own
682
+ // control surface. A row config selects it instead (`judge: true` / `auto: true`), which is how
683
+ // a preset turns routing on with no environment variable at all: the preset IS the switch.
684
+ const spec = cfg.auto === true ? 'auto' : cfg.judge === true ? 'judge' : resolveSchedule();
685
+ if (spec === null) {
686
+ log('no arm configured -- leaving every route untouched');
687
+ return;
688
+ }
689
+
690
+ const respectExplicit = process.env.ROUTEEXP_RESPECT_EXPLICIT === '1';
691
+ const judging = spec === 'judge';
692
+ const auto = spec === 'auto';
693
+ const schedule = judging || auto ? null : spec;
694
+ const constant = judging || auto || schedule.length === 1;
695
+ // Where a level -> route policy belongs: the row that wants a different one states it, and the
696
+ // built-in table stays the default the experiment measures.
697
+ const table = { ...ROUTE_TABLE, ...(cfg.routes || {}) };
698
+ const tiers = { ...TIER_TABLE, ...(cfg.tiers || {}) };
699
+ // `{ global: true }` covers every session in the process, which is what the experiment's arm
700
+ // selector needs at the host plane. A preset row is already scoped to its own agents and must NOT
701
+ // be global -- that would let one Auto session re-route another session's requests.
702
+ const useGlobal = cfg.global !== false;
703
+ /**
704
+ * The mode read for the *next* request. `installModeSetting` overwrites `mode` on every settings
705
+ * commit, so this is a live value; until the registration lands (or in a composition with no
706
+ * settings plane) it stays at the mount-time mode, which is the pre-switch behaviour.
707
+ */
708
+ const modeSink = { mode: auto ? 'auto' : 'manual' };
709
+ /** True when the user switched to manual: leave the request on the config its own owner chose. */
710
+ const isManual = () => currentMode(ctx, modeSink.mode) === 'manual';
711
+
712
+ if (auto) {
713
+ log(`auto tier per turn; tiers low=${routeLabel(tiers.low)} `
714
+ + `high=${routeLabel(tiers.high)} max=${routeLabel(tiers.max)} `
715
+ + `fallback=${routeLabel(tiers.fallback)}`);
716
+ // Bring the judge up in the background so mounting the row is all a user has to do. NOT awaited:
717
+ // an 807 MB CPU checkpoint load must never sit in front of DSH's boot, and `judgeTier` already
718
+ // fails safe per turn while it is still coming up. `autoStart: false` opts out for anyone who
719
+ // runs the service some other way.
720
+ Promise.resolve()
721
+ .then(() => ensureJudgeService(cfg))
722
+ .then((outcome) => { log(`judge service: ${outcome}`); })
723
+ .catch((e) => { log(`judge service: auto-start skipped (${e.message})`); });
724
+ // The runtime switch. Not awaited either: registering it must not delay the row, and the read
725
+ // path already falls back to `modeFallback` until the registration lands.
726
+ installModeSetting(ctx, 'auto', modeSink).then((settings) => {
727
+ if (settings !== null) log(`mode switch ready (${MODE_NS}.mode, default auto)`);
728
+ });
729
+ // The chip's side of the same switch: a same-origin route, because a static client bundle has no
730
+ // `host.call` and no `ctx.remote.settings`.
731
+ installModeRoute(ctx, modeSink);
732
+ } else if (judging) {
733
+ log(`judging every turn; table easy=${routeLabel(table.easy)} `
734
+ + `hard=${routeLabel(table.hard)} fallback=${routeLabel(table.fallback)}`);
735
+ } else {
736
+ log(constant
737
+ ? `forcing every request to ${routeLabel(schedule[0])}`
738
+ : `cycling ${schedule.length} arms per request: ${schedule.map(routeLabel).join(' | ')}`);
739
+ }
740
+ if (respectExplicit) log('deferring to any request that already names a reasoning effort');
741
+
742
+ // Request index per session, so a cycle is per session rather than per process: concurrent runs
743
+ // would otherwise share one counter and land on unpredictable arms.
744
+ const counts = new Map();
745
+ /** session key -> { turn, route } -- the verdict already applied to that turn; route null means
746
+ * "no route this profile can serve", cached so one turn does not re-ask on every step. */
747
+ const verdicts = new Map();
748
+ /** session key -> the newest claimed user text, so every turn is judged on its own prompt. */
749
+ const tasks = new Map();
750
+ /** session key -> { tier, task, turn } -- AUTO-mode session state written at the END of a turn:
751
+ * the tier we served and the task text we served it for, feeding the next turn's
752
+ * prev_tier/prev_task (regenerate detection + C3 escalation). */
753
+ const sessionState = new Map();
754
+ const keyOf = (payload) => {
755
+ const session = payload && payload.agent && payload.agent.session;
756
+ return (session && (session.id || session.sessionId)) || 'global';
757
+ };
758
+
759
+ if (judging || auto) {
760
+ // The task text is captured HERE, not at `agent/request`. Measured on a live session, the surface
761
+ // at request time holds only [permission/preset, sandbox/mode, approval/policy,
762
+ // agent/inbox/spliced, turn/start, agent/inbox/spliced, step/start] and `inbox.nextTurn` is
763
+ // already empty -- the user's own message is not observable from the request seam at all.
764
+ // `agent/inbox/claimed` fires as that message leaves the inbox, which is the last moment it can be
765
+ // read, so each prompt is remembered against its session and judged on the way out.
766
+ //
767
+ // OVERWRITTEN, not kept. Judging the session once and locking it to its first prompt would pin a
768
+ // conversation that opens with chitchat to the chitchat tier for the rest of its life.
769
+ ctx.on('agent/inbox/claimed', (payload) => {
770
+ const key = keyOf(payload);
771
+ const text = textOf(payload && payload.message);
772
+ if (text === '') return;
773
+ tasks.set(key, {
774
+ text: text.slice(0, JUDGE_MAX_CHARS),
775
+ sessionId: payload && payload.agent && payload.agent.session && payload.agent.session.id,
776
+ });
777
+ }, { global: useGlobal });
778
+ }
779
+
780
+ ctx.on('agent/request', async (payload, next) => {
781
+ const config = await next();
782
+ const key = keyOf(payload);
783
+ // The runtime switch, read HERE so flipping it takes effect on the next model call rather than at
784
+ // the next restart. Manual means exactly what it says: the request keeps the config its own owner
785
+ // chose, and nothing below runs.
786
+ if (auto && isManual()) {
787
+ return config;
788
+ }
789
+ if (respectExplicit && carriesExplicitRoute(config)) {
790
+ // v2.1: in auto mode, an explicit effort that matches what WE served this session is our
791
+ // own footprint -- re-route it (that is the whole point of auto). Anything foreign
792
+ // (subagent delegation, a pinned default) still defers. v1 paths keep the blanket defer.
793
+ if (auto && isOurRoute(config, sessionState.get(key), tiers)) {
794
+ log('explicit effort is our own last applied route -- re-routing');
795
+ } else {
796
+ log('request already names a reasoning effort -- deferring');
797
+ return config;
798
+ }
799
+ }
800
+ if (auto) {
801
+ // AUTO (plugin v2): the current task text comes from `tasks` (claimed this turn); the
802
+ // previous tier/task come from `sessionState` (written at the END of the previous turn) --
803
+ // two maps with two writers, deliberately (deepseek review F7). Same per-turn caching as
804
+ // the judge path; the state map carries `turn` so a session reset / id reuse (turn going
805
+ // backwards) discards stale prev_tier/prev_task instead of leaking them (deepseek F4).
806
+ const turn = payload && payload.turn;
807
+ const cached = verdicts.get(key);
808
+ if (cached !== undefined && cached.turn === turn) {
809
+ return cached.route === null ? config : applyRoute(config, cached.route);
810
+ }
811
+ const task = tasks.get(key);
812
+ const prev = sessionState.get(key);
813
+ const state = prev !== undefined && (turn === undefined || prev.turn === undefined || turn >= prev.turn)
814
+ ? prev
815
+ : undefined;
816
+ const judgment = task === undefined
817
+ ? null
818
+ : await judgeTier(task.text, state ? state.tier : undefined, state ? state.task : undefined,
819
+ task.sessionId, payload && payload.signal);
820
+ // Fail-safe, two branches (plan §4.4): /judge unreachable -> the fallback ROUTE (low);
821
+ // a route this profile cannot serve -> leave the request on its own config (usableRoute
822
+ // returns null, v1 semantics -- NOT the fallback).
823
+ let route;
824
+ let servedTier;
825
+ if (judgment === null) {
826
+ route = usableRoute(ctx, tiers.fallback);
827
+ servedTier = 'low';
828
+ } else {
829
+ route = usableRoute(ctx, tiers[judgment.tier] || tiers.fallback);
830
+ servedTier = judgment.tier;
831
+ }
832
+ verdicts.set(key, { turn, route });
833
+ if (task !== undefined) {
834
+ sessionState.set(key, { tier: servedTier, task: task.text, turn });
835
+ }
836
+ log(`turn ${turn} auto "${task === undefined ? '(no task text captured)' : task.text.slice(0, 60).replace(/\s+/g, ' ')}"`
837
+ + ` -> ${judgment === null ? 'judge unreachable -> fallback' : `${judgment.tier} (${judgment.triggered_by})`} -> `
838
+ + (route === null ? 'left on its own config' : routeLabel(route)));
839
+ return route === null ? config : applyRoute(config, route);
840
+ }
841
+ if (judging) {
842
+ const turn = payload && payload.turn;
843
+ const cached = verdicts.get(key);
844
+ // One judgment per TURN, held across every step of it. An agentic turn makes many model calls,
845
+ // and a tier that moved mid-turn would fight both the KV cache and the tool calls already in
846
+ // flight -- so this is "route each task once", not "route every tool-call round".
847
+ if (cached !== undefined && cached.turn === turn) {
848
+ return cached.route === null ? config : applyRoute(config, cached.route);
849
+ }
850
+ const task = tasks.get(key);
851
+ const level = task === undefined
852
+ ? null
853
+ : await judgeDifficulty(ctx, task.text, task.sessionId, payload && payload.signal);
854
+ const route = usableRoute(ctx, routeForLevel(level, table));
855
+ verdicts.set(key, { turn, route });
856
+ log(`turn ${turn} judged "${task === undefined ? '(no task text captured)' : task.text.slice(0, 60).replace(/\s+/g, ' ')}"`
857
+ + ` -> ${level === null ? 'unjudged' : level} -> `
858
+ + (route === null ? 'left on its own config' : routeLabel(route)));
859
+ return route === null ? config : applyRoute(config, route);
860
+ }
861
+ const index = counts.get(key) || 0;
862
+ counts.set(key, index + 1);
863
+ const route = schedule[index % schedule.length];
864
+ if (!constant) {
865
+ process.stderr.write(`[router-laya] request #${index} (turn ${payload && payload.turn}, step `
866
+ + `${payload && payload.step}) -> ${routeLabel(route)}\n`);
867
+ }
868
+ return applyRoute(config, route);
869
+ }, { global: useGlobal });
870
+ }