dsh-switchman 1.11.0 → 1.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +6 -6
- package/README.es.md +6 -6
- package/README.fr.md +6 -6
- package/README.it.md +6 -6
- package/README.ja.md +6 -6
- package/README.ko.md +6 -6
- package/README.md +6 -6
- package/README.pt.md +6 -6
- package/README.ru.md +6 -6
- package/README.zh-TW.md +6 -6
- package/README.zh.md +6 -6
- package/client.js +76 -9
- package/cordis.patch.yml +30 -1
- package/host/config.js +28 -13
- package/host/context-trim/index.js +53 -12
- package/host/context-trim/metrics.js +17 -11
- package/host/context-trim/summarizer.js +38 -7
- package/host/context-trim/trigger.js +3 -3
- package/host/context-watch.js +83 -9
- package/host/dispatch.js +251 -8
- package/host/routes.js +44 -8
- package/host/teams.js +4 -2
- package/host/tool-args-sanitize.js +106 -0
- package/index.js +10 -0
- package/package.json +1 -1
|
@@ -225,13 +225,15 @@ function describeFailure(chunk) {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
/** Side-model summary. llm: the dsh llm service (receiver-bound methods);
|
|
228
|
-
* route: { provider, model } | null (null → caller skips); materialText:
|
|
229
|
-
* renderMaterial output; options.systemPrompt overrides the default
|
|
228
|
+
* route: { provider, model, effort? } | null (null → caller skips); materialText:
|
|
230
229
|
* compression prompt (the ladder distillation passes its own); deadlineMs:
|
|
231
230
|
* hard budget. Returns the summary text, or null on ANY failure (caller
|
|
232
231
|
* skips the fold cycle). */
|
|
233
232
|
export async function summarizeWithModel(llm, route, materialText, options = {}) {
|
|
234
233
|
const fail = typeof options.onFail === "function" ? options.onFail : null;
|
|
234
|
+
// Effort-retry notice: fires BEFORE the low-pinned retry, whether or not
|
|
235
|
+
// the retry lands. Falls back to fail when the caller doesn't supply one.
|
|
236
|
+
const notice = typeof options.onNotice === "function" ? options.onNotice : null;
|
|
235
237
|
if (route === null || route === undefined) { fail?.("no-route"); return null; }
|
|
236
238
|
if (typeof llm?.prepareCall !== "function") { fail?.("no-prepare-call"); return null; }
|
|
237
239
|
const deadlineMs = Number.isFinite(options.deadlineMs) && options.deadlineMs > 0 ? options.deadlineMs : SUMMARY_TIMEOUT_MS;
|
|
@@ -240,11 +242,40 @@ export async function summarizeWithModel(llm, route, materialText, options = {})
|
|
|
240
242
|
const controller = new AbortController();
|
|
241
243
|
const timer = setTimeout(() => controller.abort(new Error("trim summary deadline")), deadlineMs);
|
|
242
244
|
try {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
245
|
+
// Effort policy: inherit the route's effort verbatim when it carries
|
|
246
|
+
// one (session-inherited routes carry the session's own DSH-resolved
|
|
247
|
+
// effort; explicit trimSideModel configs may pin theirs), else pin
|
|
248
|
+
// "low" — summaries are auxiliary work. NEVER send without an effort,
|
|
249
|
+
// and never send "off": pi-ai maps both to thinking:{type:"disabled"}
|
|
250
|
+
// (profileOptions turns "off" back into an absent reasoning option),
|
|
251
|
+
// which newer Anthropic endpoints reject with 400. A DSH session run
|
|
252
|
+
// with thinking disabled resolves its effort to "off", so an inherited
|
|
253
|
+
// route can carry exactly that value — treat it like empty here.
|
|
254
|
+
// The effort rides the prepareCall proposal so it resolves into
|
|
255
|
+
// call.config, keeping stream options consistent with the prepared
|
|
256
|
+
// call (INVALID_PREPARED_CALL guard).
|
|
257
|
+
const requestedEffort = typeof route.effort === "string" && route.effort !== "" && route.effort !== "off" ? route.effort : "low";
|
|
258
|
+
let call;
|
|
259
|
+
try {
|
|
260
|
+
call = await llm.prepareCall(
|
|
261
|
+
{ provider: route.provider, model: route.model, maxTokens, reasoningEffort: requestedEffort },
|
|
262
|
+
controller.signal,
|
|
263
|
+
(controls) => controls,
|
|
264
|
+
);
|
|
265
|
+
} catch (prepareError) {
|
|
266
|
+
// An inherited session effort can exceed what the route's adapter
|
|
267
|
+
// supports: retry once pinned low before giving up (the outer
|
|
268
|
+
// catch then turns the failure into a skipped fold cycle).
|
|
269
|
+
const code = prepareError?.code ?? null;
|
|
270
|
+
const named = typeof prepareError?.message === "string" && prepareError.message.includes("UNSUPPORTED_REASONING_EFFORT");
|
|
271
|
+
if (requestedEffort === "low" || (code !== "UNSUPPORTED_REASONING_EFFORT" && !named)) throw prepareError;
|
|
272
|
+
(notice ?? fail)?.(`effort "${requestedEffort}" unsupported, retrying low`);
|
|
273
|
+
call = await llm.prepareCall(
|
|
274
|
+
{ provider: route.provider, model: route.model, maxTokens, reasoningEffort: "low" },
|
|
275
|
+
controller.signal,
|
|
276
|
+
(controls) => controls,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
248
279
|
let text = "";
|
|
249
280
|
let finishKind = null;
|
|
250
281
|
// stream options MUST carry the prepared call config — the adapter
|
|
@@ -132,7 +132,7 @@ export function evaluateTrigger(input) {
|
|
|
132
132
|
// Side-model folds (pressure + growth + ladder) share the cadence gates.
|
|
133
133
|
const lastFoldAt = Number.isFinite(input?.lastFoldAt) ? input.lastFoldAt : 0;
|
|
134
134
|
const hourly = Array.isArray(input?.foldsThisHour) ? input.foldsThisHour.filter((ts) => now - ts < 3_600_000).length : 0;
|
|
135
|
-
const maxFoldsPerHour = Number.isInteger(thresholds.maxFoldsPerHour) && thresholds.maxFoldsPerHour > 0 ? thresholds.maxFoldsPerHour :
|
|
135
|
+
const maxFoldsPerHour = Number.isInteger(thresholds.maxFoldsPerHour) && thresholds.maxFoldsPerHour > 0 ? thresholds.maxFoldsPerHour : 30;
|
|
136
136
|
const hourlyBlocked = hourly >= maxFoldsPerHour;
|
|
137
137
|
|
|
138
138
|
if (foldable !== null && incrementTokens >= 1) {
|
|
@@ -187,7 +187,7 @@ export function evaluateTrigger(input) {
|
|
|
187
187
|
}
|
|
188
188
|
if (!hourlyBlocked && now - lastFoldAt >= FOLD_MIN_INTERVAL_MS) {
|
|
189
189
|
// Tier b — pressure: soft window threshold, side-model.
|
|
190
|
-
const pressurePercent = Number.isFinite(thresholds.pressurePercent) && thresholds.pressurePercent > 0 ? thresholds.pressurePercent :
|
|
190
|
+
const pressurePercent = Number.isFinite(thresholds.pressurePercent) && thresholds.pressurePercent > 0 ? thresholds.pressurePercent : 65;
|
|
191
191
|
if (windowTokens !== null) {
|
|
192
192
|
const usagePct = (input.viewTokens / windowTokens) * 100;
|
|
193
193
|
if (usagePct >= pressurePercent) {
|
|
@@ -196,7 +196,7 @@ export function evaluateTrigger(input) {
|
|
|
196
196
|
}
|
|
197
197
|
// Tier a — growth gate: the primary cache-friendly heuristic.
|
|
198
198
|
const growthTokens = Number.isFinite(thresholds.growthTokens) && thresholds.growthTokens > 0 ? thresholds.growthTokens : 22_500;
|
|
199
|
-
const incrementGate = Number.isFinite(thresholds.incrementTokens) && thresholds.incrementTokens > 0 ? thresholds.incrementTokens :
|
|
199
|
+
const incrementGate = Number.isFinite(thresholds.incrementTokens) && thresholds.incrementTokens > 0 ? thresholds.incrementTokens : 25_000;
|
|
200
200
|
// Growth anchor: the post-fold low when known (keeps the gate armed
|
|
201
201
|
// after folds that commit at high view points), else the pre-fold
|
|
202
202
|
// view for ledgers written before lastFoldLowTokens existed.
|
package/host/context-watch.js
CHANGED
|
@@ -53,7 +53,7 @@ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
53
53
|
import { dirname, join } from "node:path";
|
|
54
54
|
import { clearHandover, handoverOf, recordHandover } from "./handover-state.js";
|
|
55
55
|
import { enumerateLiveSubagents, sessionRecordPath, sessionRecordsDir } from "./subagents.js";
|
|
56
|
-
import { recentTrimPromptTokensOf } from "./context-trim/index.js";
|
|
56
|
+
import { recentTrimModelOf, recentTrimPromptTokensOf } from "./context-trim/index.js";
|
|
57
57
|
|
|
58
58
|
/** Tools whose result text is accounted against the per-turn read budget. */
|
|
59
59
|
const ACCOUNT_TOOLS = new Set(["read", "glob", "grep", "bash"]);
|
|
@@ -154,8 +154,9 @@ export function measureNow(ctx, agent) {
|
|
|
154
154
|
// longer receives — high bias), while the bare view never counts the
|
|
155
155
|
// system/tool sections (low bias). max(view, wire) fixes both and puts
|
|
156
156
|
// the watermark tiers (soft/hard/force + read budget) on the SAME
|
|
157
|
-
// boundary as the fold engine — wmHardTokens 95k
|
|
158
|
-
// (
|
|
157
|
+
// boundary as the fold engine — wmHardTokens 95k ≈ trim pressure
|
|
158
|
+
// 97.5k (65% of the 150k window) — holds in units, not just in
|
|
159
|
+
// numbers. Strictly additive +
|
|
159
160
|
// fail-open: any trim failure falls back to the stored measure.
|
|
160
161
|
try {
|
|
161
162
|
const sessionId = agent?.session?.id ?? (typeof agent?.id === "string" ? agent.id : null);
|
|
@@ -240,6 +241,69 @@ function renderSubWatermark(level) {
|
|
|
240
241
|
);
|
|
241
242
|
}
|
|
242
243
|
|
|
244
|
+
/** Freshness TTL for resolved subagent model windows (same cadence as the
|
|
245
|
+
* trim engine's windowCache: 10 minutes, negative results cached too). */
|
|
246
|
+
const SUB_WINDOW_TTL_MS = 600_000;
|
|
247
|
+
|
|
248
|
+
/** Synchronously read the resolved context window (tokens) of the model a
|
|
249
|
+
* subagent session is running on, or null when unknown/stale. On a cache
|
|
250
|
+
* miss it primes the resolution in the background and returns null — the
|
|
251
|
+
* first render falls back to the static tiers (fail-open), later renders
|
|
252
|
+
* see the scaled tiers. Resolution goes through ctx.get("llm").
|
|
253
|
+
* resolveModelInfo, the same DSH channel the trim engine uses. */
|
|
254
|
+
function subagentWindowTokens(ctx, state, sessionId) {
|
|
255
|
+
try {
|
|
256
|
+
const route = recentTrimModelOf(sessionId);
|
|
257
|
+
if (route === null) return null;
|
|
258
|
+
const key = `${route.provider}/${route.model}`;
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
const entry = state.subWindow.get(key);
|
|
261
|
+
if (entry !== undefined && now - entry.at < SUB_WINDOW_TTL_MS) return entry.value;
|
|
262
|
+
if (entry === undefined || entry.priming !== true) {
|
|
263
|
+
// Occupy the slot so concurrent renders do not fan out resolutions;
|
|
264
|
+
// at=0 keeps the read path treating it as a miss until primed.
|
|
265
|
+
state.subWindow.set(key, { value: null, at: 0, priming: true });
|
|
266
|
+
void primeSubagentWindow(ctx, state, route, key);
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Async window resolution backing subagentWindowTokens; stores the raw
|
|
275
|
+
* resolved window (null when unknown — negative-cached for the same TTL). */
|
|
276
|
+
async function primeSubagentWindow(ctx, state, route, key) {
|
|
277
|
+
try {
|
|
278
|
+
const llm = ctx.get?.("llm", false);
|
|
279
|
+
const info = await llm?.resolveModelInfo?.({ provider: route.provider, model: route.model });
|
|
280
|
+
const windowTokens = info?.context?.contextWindow;
|
|
281
|
+
const value = Number.isFinite(windowTokens) && windowTokens > 0 ? windowTokens : null;
|
|
282
|
+
state.subWindow.set(key, { value, at: Date.now() });
|
|
283
|
+
if (state.subWindow.size > 200) state.subWindow.delete(state.subWindow.keys().next().value);
|
|
284
|
+
} catch {
|
|
285
|
+
state.subWindow.set(key, { value: null, at: Date.now() });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Pure subagent tier scaling (exported for tests): cap force at
|
|
290
|
+
* windowTokens × ratio and shrink soft/hard by the same factor (floored at
|
|
291
|
+
* 1000/2000) so tier spacing and ordering survive the shrink. Tighten-only:
|
|
292
|
+
* a null/non-positive window, a cap ≥ the input force, or a cap below 3000
|
|
293
|
+
* returns the tiers unchanged (fail-open). */
|
|
294
|
+
export function scaleSubagentTiers(th, windowTokens, ratio) {
|
|
295
|
+
const subTh = { ...th };
|
|
296
|
+
if (!Number.isFinite(windowTokens) || windowTokens <= 0) return subTh;
|
|
297
|
+
const cap = Math.floor(windowTokens * ratio);
|
|
298
|
+
if (cap < subTh.force && cap >= 3000) {
|
|
299
|
+
const factor = cap / subTh.force;
|
|
300
|
+
subTh.soft = Math.max(1000, Math.floor(subTh.soft * factor));
|
|
301
|
+
subTh.hard = Math.max(2000, Math.floor(subTh.hard * factor));
|
|
302
|
+
subTh.force = cap;
|
|
303
|
+
}
|
|
304
|
+
return subTh;
|
|
305
|
+
}
|
|
306
|
+
|
|
243
307
|
/** Whole live line for one assembly (the tail context); "" whenever anything
|
|
244
308
|
* needed is missing or the session sits at the silent ok tier. */
|
|
245
309
|
function renderWatermarkContext(ctx, config, state, agent) {
|
|
@@ -249,16 +313,25 @@ function renderWatermarkContext(ctx, config, state, agent) {
|
|
|
249
313
|
const th = readThresholds(config);
|
|
250
314
|
const used = measureNow(ctx, agent);
|
|
251
315
|
if (used === null) return "";
|
|
252
|
-
const level = levelOf(used, th);
|
|
253
|
-
if (level === "ok") return "";
|
|
254
316
|
if (!isRoot(ctx, agent)) {
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
317
|
+
// Subagent tiers: root baseline, optional static force override, then
|
|
318
|
+
// capped by the subagent's OWN model window (ratio × window), so a
|
|
319
|
+
// small-window child model gets proportionally earlier tiers instead
|
|
320
|
+
// of inheriting tiers sized for the root model. Scaling can only
|
|
321
|
+
// TIGHTEN (min), never loosen; soft/hard shrink by the same factor to
|
|
322
|
+
// preserve tier spacing. Window resolution is async, so the render
|
|
323
|
+
// path reads a primed cache and primes in the background on miss —
|
|
324
|
+
// first renders fall back to the static tiers (fail-open).
|
|
258
325
|
const subForce = readBool(config.wmSubagentCap, true) ? readNumber(config.wmSubagentForceTokens, 0) : 0;
|
|
259
|
-
const subTh =
|
|
326
|
+
const subTh = scaleSubagentTiers(
|
|
327
|
+
subForce > 0 ? { ...th, force: subForce } : { ...th },
|
|
328
|
+
subagentWindowTokens(ctx, state, sessionId),
|
|
329
|
+
readNumber(config.wmSubagentWindowRatio, 0.75),
|
|
330
|
+
);
|
|
260
331
|
return renderSubWatermark(levelOf(used, subTh));
|
|
261
332
|
}
|
|
333
|
+
const level = levelOf(used, th);
|
|
334
|
+
if (level === "ok") return "";
|
|
262
335
|
if (state.paused.has(sessionId)) return PAUSED_LINE;
|
|
263
336
|
const live = handoverOf(sessionId);
|
|
264
337
|
const elapsed = Date.now() - (state.lastHandover.get(sessionId) ?? 0);
|
|
@@ -332,6 +405,7 @@ function applyContextWatch(ctx, config) {
|
|
|
332
405
|
handoverTool: null,
|
|
333
406
|
lastHandover: new Map(),
|
|
334
407
|
readUsed: new Map(),
|
|
408
|
+
subWindow: new Map(), // "provider/model" -> { value: number|null, at } — resolved subagent model windows (see subagentWindowTokens)
|
|
335
409
|
};
|
|
336
410
|
const warn = (message) => ctx.logger?.warn?.(`dsh-switchman: ${message}`);
|
|
337
411
|
|
package/host/dispatch.js
CHANGED
|
@@ -15,7 +15,14 @@
|
|
|
15
15
|
* pushes the caller back to implicit dispatch — silently losing the lane's
|
|
16
16
|
* route. The sanitation denies such calls with the exact corrected
|
|
17
17
|
* arguments instead (exec.arguments is deep-frozen by the runtime, so a
|
|
18
|
-
* deny is the only correction channel `tools/pre-execute` offers).
|
|
18
|
+
* deny is the only correction channel `tools/pre-execute` offers). The
|
|
19
|
+
* same pass covers the field-shape failures production logs show models
|
|
20
|
+
* repeating (a merged "provider/model" string with the provider field
|
|
21
|
+
* omitted, a bare model with the provider field omitted, a model still
|
|
22
|
+
* carrying its "provider/" prefix, and a provider-less
|
|
23
|
+
* list_subagent_models call) — every such call is rejected by DSH
|
|
24
|
+
* outright, so each deny carries the exact corrected fields and turns
|
|
25
|
+
* 2-4 blind retries into one immediate re-dispatch.
|
|
19
26
|
*
|
|
20
27
|
* Model-facing text is English and byte-stable for a given configuration
|
|
21
28
|
* (the anchor line never varies with UI locale). All reads go through the
|
|
@@ -284,15 +291,242 @@ function annotationFixOf(args, config) {
|
|
|
284
291
|
);
|
|
285
292
|
}
|
|
286
293
|
|
|
294
|
+
/** Every configured {provider, model} route across all lanes and
|
|
295
|
+
* modelRank, in config order. Unlike configuredRoutes (whose folded key
|
|
296
|
+
* is first-wins per model segment — right for route MATCHING, where
|
|
297
|
+
* duplicates would collapse), this keeps every entry: provider-candidate
|
|
298
|
+
* hints must see all providers offering the same model id. */
|
|
299
|
+
function allConfiguredRoutes(config) {
|
|
300
|
+
const routes = [];
|
|
301
|
+
const add = (route) => {
|
|
302
|
+
if (isModelRoute(route)) routes.push(route);
|
|
303
|
+
};
|
|
304
|
+
for (const lane of LANES) for (const route of readList(config[laneField(lane)])) add(route);
|
|
305
|
+
for (const entry of readList(config.modelRank)) add(entry);
|
|
306
|
+
return routes;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Distinct configured providers whose folded model key matches one bare
|
|
310
|
+
* model id, in config order — completes a corrective hint for a
|
|
311
|
+
* delegation or enumeration call that omitted the provider field. */
|
|
312
|
+
function modelProviderMatches(config, model) {
|
|
313
|
+
const key = normalizeModelKey("", model);
|
|
314
|
+
if (key === "") return [];
|
|
315
|
+
const seen = new Set();
|
|
316
|
+
const providers = [];
|
|
317
|
+
for (const route of allConfiguredRoutes(config)) {
|
|
318
|
+
if (normalizeModelKey("", route.model) !== key || seen.has(route.provider)) continue;
|
|
319
|
+
seen.add(route.provider);
|
|
320
|
+
providers.push(route.provider);
|
|
321
|
+
}
|
|
322
|
+
return providers;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Canonical pool spelling for a folded model-id hit (caller casing
|
|
326
|
+
* and annotation tail vary); the trimmed input when no pool route
|
|
327
|
+
* folds to it. */
|
|
328
|
+
function canonicalPoolModel(config, providers, model) {
|
|
329
|
+
const hit = allConfiguredRoutes(config).find(
|
|
330
|
+
(route) =>
|
|
331
|
+
providers.includes(route.provider) &&
|
|
332
|
+
normalizeModelKey("", route.model) === normalizeModelKey("", model),
|
|
333
|
+
);
|
|
334
|
+
return hit !== undefined ? hit.model : model;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Corrective deny reason for one delegation call whose provider/model
|
|
338
|
+
* field SPLIT is malformed, or null to pass through. Covers the field
|
|
339
|
+
* failures observed in production session logs (each burned 2-4 blind
|
|
340
|
+
* retries before a manual fix):
|
|
341
|
+
*
|
|
342
|
+
* - provider omitted, model a merged "provider/model" string: DSH rejects
|
|
343
|
+
* EVERY such call with "`provider` and `model` must be supplied
|
|
344
|
+
* together" before any route check, so denying with the split fields is
|
|
345
|
+
* a strict improvement even when the split is not a configured route —
|
|
346
|
+
* a deliberate exception to the annotated-copy rule that unknown ids
|
|
347
|
+
* pass through (that rule protects calls DSH would ACCEPT; these can
|
|
348
|
+
* never succeed);
|
|
349
|
+
* - provider given, model still carrying that same "provider/" prefix:
|
|
350
|
+
* DSH joins provider + "/" + model itself, so the resolved route
|
|
351
|
+
* double-encodes the provider and misses the exact-match whitelist;
|
|
352
|
+
* - provider omitted, model a bare id: resolved against the pool table —
|
|
353
|
+
* exactly one configured provider → the pair; several → the candidate
|
|
354
|
+
* list; none → pass through (DSH's own error already says what's wrong);
|
|
355
|
+
* - provider given, model naming a DIFFERENT provider's configured route:
|
|
356
|
+
* denied with that route's pair; anything else passes untouched. */
|
|
357
|
+
function routeFixOf(args, config) {
|
|
358
|
+
const model = typeof args?.model === "string" ? args.model.trim() : "";
|
|
359
|
+
if (model === "") return null;
|
|
360
|
+
const providerGiven = typeof args?.provider === "string" && args.provider.trim() !== "";
|
|
361
|
+
const provider = providerGiven ? args.provider.trim() : "";
|
|
362
|
+
|
|
363
|
+
// Provider given, model still prefixed: the double-encoded route can
|
|
364
|
+
// never match the exact whitelist, so the correction is unconditional.
|
|
365
|
+
if (providerGiven && model.startsWith(`${provider}/`)) {
|
|
366
|
+
const fixed = model.slice(provider.length + 1).trim();
|
|
367
|
+
if (fixed === "" || fixed.includes("/")) return null;
|
|
368
|
+
return (
|
|
369
|
+
`[SWITCHMAN:POOLS] the model argument already carries the provider prefix — DSH joins provider + "/" + model itself, so this call resolves to the route "${provider}/${model}" and is rejected. ` +
|
|
370
|
+
`Re-dispatch with provider "${provider}", model "${fixed}", keeping every other argument unchanged.`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const cut = model.lastIndexOf("/");
|
|
375
|
+
|
|
376
|
+
// Provider given, model names another provider's configured route.
|
|
377
|
+
if (providerGiven && cut > 0) {
|
|
378
|
+
const prefix = model.slice(0, cut).trim();
|
|
379
|
+
const known =
|
|
380
|
+
prefix === ""
|
|
381
|
+
? undefined
|
|
382
|
+
: allConfiguredRoutes(config).find(
|
|
383
|
+
(route) =>
|
|
384
|
+
route.provider.toLowerCase() === prefix.toLowerCase() &&
|
|
385
|
+
normalizeModelKey("", route.model) === normalizeModelKey("", model.slice(cut + 1).trim()),
|
|
386
|
+
);
|
|
387
|
+
// No configured route under that prefix: not a shape we can correct
|
|
388
|
+
// — pass through for DSH's own rejection, without tripping the
|
|
389
|
+
// fail-open catch.
|
|
390
|
+
if (known === undefined) return null;
|
|
391
|
+
return (
|
|
392
|
+
`[SWITCHMAN:POOLS] the model argument embeds another provider's route — with provider "${provider}" this resolves to "${provider}/${model}", which no authorized route matches. ` +
|
|
393
|
+
`Re-dispatch with provider "${known.provider}", model "${known.model}", keeping every other argument unchanged.`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (providerGiven) return null;
|
|
398
|
+
|
|
399
|
+
// Provider omitted, merged "provider/model" string: always rejected by
|
|
400
|
+
// DSH (both fields are required together), deny with the split fields.
|
|
401
|
+
if (cut > 0) {
|
|
402
|
+
// A merged copy of the already-prefixed id ("prov/prov/model") —
|
|
403
|
+
// the repeated head segment is the giveaway; recommend the inner
|
|
404
|
+
// pair directly. Other mid-slash shapes stay uncorrectable.
|
|
405
|
+
const segments = model.split("/");
|
|
406
|
+
const doubled =
|
|
407
|
+
segments.length === 3 &&
|
|
408
|
+
segments[0].trim().toLowerCase() === segments[1].trim().toLowerCase();
|
|
409
|
+
const providerFix = (doubled ? segments[0] : model.slice(0, cut)).trim();
|
|
410
|
+
const modelFix = stripTrailingAnnotations((doubled ? segments[2] : model.slice(cut + 1)).trim());
|
|
411
|
+
// Segments still malformed after the split (a mid-slash provider,
|
|
412
|
+
// a leading-slash or nested model) cannot be corrected into a call
|
|
413
|
+
// DSH would accept — pass through.
|
|
414
|
+
if (
|
|
415
|
+
providerFix === "" ||
|
|
416
|
+
modelFix === "" ||
|
|
417
|
+
providerFix.includes("/") ||
|
|
418
|
+
modelFix.includes("/")
|
|
419
|
+
)
|
|
420
|
+
return null;
|
|
421
|
+
const matched = allConfiguredRoutes(config).find(
|
|
422
|
+
(route) =>
|
|
423
|
+
route.provider.toLowerCase() === providerFix.toLowerCase() &&
|
|
424
|
+
normalizeModelKey("", route.model) === normalizeModelKey("", modelFix),
|
|
425
|
+
);
|
|
426
|
+
// Recommend the pool's canonical spelling on a folded hit so
|
|
427
|
+
// case-variant or annotated copies resolve on the first retry.
|
|
428
|
+
const shownProvider = matched !== undefined ? matched.provider : providerFix;
|
|
429
|
+
const shownModel = matched !== undefined ? matched.model : modelFix;
|
|
430
|
+
const note =
|
|
431
|
+
matched === undefined
|
|
432
|
+
? `If "${shownProvider}/${shownModel}" is not authorized for this session, the re-dispatch error will say so precisely.`
|
|
433
|
+
: `This is the configured pool route ${shownProvider}/${shownModel}.`;
|
|
434
|
+
return (
|
|
435
|
+
`[SWITCHMAN:POOLS] the model argument merges provider and model into one string — subagent calls take SEPARATE provider and model fields, and DSH rejects every call that omits either. ` +
|
|
436
|
+
`Re-dispatch with provider "${shownProvider}", model "${shownModel}", keeping every other argument unchanged. ${note}`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// A leading "/" (cut === 0) is malformed beyond folding: the pool
|
|
441
|
+
// matcher would silently strip it and recommend the malformed id back.
|
|
442
|
+
// Pass through and let DSH's own schema error surface.
|
|
443
|
+
if (cut === 0) return null;
|
|
444
|
+
|
|
445
|
+
// Provider omitted, bare model id: resolve the provider from the pool
|
|
446
|
+
// table; pass through when the id names no configured provider.
|
|
447
|
+
const providers = modelProviderMatches(config, model);
|
|
448
|
+
if (providers.length === 0) return null;
|
|
449
|
+
const shownModel = canonicalPoolModel(config, providers, model);
|
|
450
|
+
if (providers.length === 1) {
|
|
451
|
+
return (
|
|
452
|
+
`[SWITCHMAN:POOLS] the provider field is missing — subagent calls take SEPARATE provider and model fields, and DSH rejects every call that omits either. ` +
|
|
453
|
+
`The model id "${model}" is configured under exactly one provider. Re-dispatch with provider "${providers[0]}", model "${shownModel}", keeping every other argument unchanged.`
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
return (
|
|
457
|
+
`[SWITCHMAN:POOLS] the provider field is missing and the model id "${model}" is configured under several providers (${providers.join(", ")}). ` +
|
|
458
|
+
`Re-dispatch with one of those providers as the provider field, model "${shownModel}", keeping every other argument unchanged.`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Corrective deny reason for a malformed list_subagent_models call, or
|
|
463
|
+
* null to pass through. DSH's own schema rejects {} and {model}-only
|
|
464
|
+
* calls with a bare "`provider` must be non-empty"; production logs show
|
|
465
|
+
* that error costing 2-4 blind probes (empty, empty, provider-only,
|
|
466
|
+
* provider+model). One deny carrying the pool table's provider list
|
|
467
|
+
* turns that into a single corrected call. provider-given calls always
|
|
468
|
+
* pass. */
|
|
469
|
+
function enumerationFixOf(args, config) {
|
|
470
|
+
const provider = typeof args?.provider === "string" ? args.provider.trim() : "";
|
|
471
|
+
if (provider !== "") return null;
|
|
472
|
+
const model = typeof args?.model === "string" ? args.model.trim() : "";
|
|
473
|
+
if (model === "") {
|
|
474
|
+
const providers = [...new Set(allConfiguredRoutes(config).map((route) => route.provider))];
|
|
475
|
+
const list = providers.length > 0 ? ` Providers configured on this session's pools: ${providers.join(", ")}.` : "";
|
|
476
|
+
return (
|
|
477
|
+
`[SWITCHMAN:POOLS] list_subagent_models requires a non-empty provider — a call without one is rejected, not a listing of everything. ` +
|
|
478
|
+
`Call it with {"provider":"<provider>"} to enumerate that provider's selectable models, then add "model" for one route's details.${list}`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
const providers = modelProviderMatches(config, model);
|
|
482
|
+
if (providers.length === 0) return null;
|
|
483
|
+
const shownModel = canonicalPoolModel(config, providers, model);
|
|
484
|
+
if (providers.length === 1) {
|
|
485
|
+
return (
|
|
486
|
+
`[SWITCHMAN:POOLS] list_subagent_models requires a non-empty provider. The model id "${model}" is configured under exactly one provider — ` +
|
|
487
|
+
`call it with provider "${providers[0]}", model "${shownModel}" for that route's details.`
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return (
|
|
491
|
+
`[SWITCHMAN:POOLS] list_subagent_models requires a non-empty provider. The model id "${model}" is configured under several providers (${providers.join(", ")}) — ` +
|
|
492
|
+
`call it with one of those providers as the provider field, model "${shownModel}".`
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The assembling session's own route, from the DSH agent-loop's assemble
|
|
497
|
+
* context (`agent.ts` passes `modelSelection` with every turn assembly;
|
|
498
|
+
* older builds and non-agent assemblies omit it). Null when absent or
|
|
499
|
+
* malformed — the session-route line then does not render (fail-open,
|
|
500
|
+
* like every capability read in this file family). */
|
|
501
|
+
function sessionRouteOf(context) {
|
|
502
|
+
const route = context === null || typeof context !== "object" ? undefined : context.modelSelection;
|
|
503
|
+
if (route === null || typeof route !== "object") return null;
|
|
504
|
+
const provider = typeof route.provider === "string" ? route.provider.trim() : "";
|
|
505
|
+
const model = typeof route.model === "string" ? route.model.trim() : "";
|
|
506
|
+
if (provider === "" || model === "") return null;
|
|
507
|
+
const effort = typeof route.reasoningEffort === "string" && route.reasoningEffort !== "" ? route.reasoningEffort : null;
|
|
508
|
+
|
|
509
|
+
return { provider, model, effort };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
|
|
287
513
|
/** The `[SWITCHMAN:POOLS]` block: anchor line, one line per configured lane,
|
|
288
514
|
* then the guidance lines (byte-stable for a given configuration). Routes
|
|
289
515
|
* outside the session's DSH-authorized child models carry a ⚠not-authorized
|
|
290
516
|
* marker (after any @effort suffix) — teams mode only: with teamsMode OFF
|
|
291
517
|
* the whitelist is never read, `state.authorized` stays null, and neither
|
|
292
|
-
* the markers nor the drift guidance lines render.
|
|
293
|
-
|
|
518
|
+
* the markers nor the drift guidance lines render. When the assembling
|
|
519
|
+
* context carries the session's own model selection, a `session route:`
|
|
520
|
+
* line follows the anchor. */
|
|
521
|
+
function renderPoolsBlock(state, sessionRoute = null) {
|
|
294
522
|
const configured = state.lanePools.filter((pool) => pool.routes.length > 0);
|
|
295
523
|
const lines = [`[SWITCHMAN:POOLS] configured=${configured.length}/6 enforce=${state.enforce}`];
|
|
524
|
+
if (sessionRoute !== null) {
|
|
525
|
+
lines.push(
|
|
526
|
+
`session route: ${sessionRoute.provider}/${sessionRoute.model}${sessionRoute.effort === null ? "" : `@${sessionRoute.effort}`}` +
|
|
527
|
+
` — implicit dispatch (omitting provider/model/effort) gives the child THIS route; when it differs from the lane entries, only an explicit dispatch lands a lane model.`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
296
530
|
let anyUnauthorized = false;
|
|
297
531
|
for (const pool of state.lanePools) {
|
|
298
532
|
if (pool.routes.length === 0) continue;
|
|
@@ -315,9 +549,11 @@ function renderPoolsBlock(state) {
|
|
|
315
549
|
`Lanes marked * are in manual order: dispatch follows the listed order exactly (the head is the first choice).`,
|
|
316
550
|
`effort: economy=medium mechanical=medium main=medium (high for complex tasks) hard=high vision=multimodal-capable model review=high; an unconfigured lane uses the system default model.`,
|
|
317
551
|
`Entries render as provider/model(TIER)@effort: (TIER) is the S/A/B/C capability grade and @effort a manually pinned reasoning effort — both are display-only annotations, NEVER parts of the model id.`,
|
|
318
|
-
`Decompose
|
|
552
|
+
`Decompose into SEPARATE tool fields: provider/model(TIER)@effort → {"provider":"<provider>","model":"<model>","reasoning_effort":"<effort>"} (a pin overrides the lane default; a route without @effort carries no per-route pin — fall back to the lane's default effort above).`,
|
|
553
|
+
`The table cell is ONE display string, never a field value: "model":"provider/model" (provider field omitted) is rejected, and model NEVER carries a "provider/" prefix — DSH joins provider + "/" + model itself, so a prefixed model double-encodes the provider and misses the whitelist.`,
|
|
319
554
|
`Review delegations: anchor independence on the code AUTHOR's model, not the main session's (author = the agent that produced the diff: its explicit dispatch route, or the inherited session model when unspecified). Prefer a reviewer model different from the author's; also avoiding the session model is a bonus (its integration pass already covers that angle). If no pool entry differs from the author, same-model review is allowed — declare DOWNGRADED and state whether it is relative to the author or the session.`,
|
|
320
|
-
`Explicit
|
|
555
|
+
`Explicit lane dispatch is the DEFAULT for subagent/workflow: name provider+model+reasoning_effort decomposed from the lane row (validated against this session's DSH-authorized child models — DSH Settings: allow agents to choose subagent models — NOT against this table). Implicit dispatch (omitting all three fields) is a LAST-RESORT fallback, never a shortcut: use it only after an explicit attempt was denied or list_subagent_models confirmed no authorized route, and declare IMPLICIT-DOWNGRADED in your report when you do — the child then uses configured defaults or inherits the parent route, so the lane's model is lost.`,
|
|
556
|
+
`list_subagent_models verifies authorized routes before dispatching: {"provider":"<provider>"} lists that provider's models, {"provider":"<provider>","model":"<model>"} returns one route's details — {} and {"model"} alone are rejected (provider is required and non-empty).`,
|
|
321
557
|
);
|
|
322
558
|
if (anyUnauthorized) {
|
|
323
559
|
lines.push(
|
|
@@ -353,11 +589,11 @@ function applyDispatch(ctx, config) {
|
|
|
353
589
|
name: "switchman:dispatch",
|
|
354
590
|
order: 10400,
|
|
355
591
|
interpolate: false,
|
|
356
|
-
text: () => {
|
|
592
|
+
text: (context) => {
|
|
357
593
|
const state = readDispatchState(ctx, config);
|
|
358
594
|
const anyConfigured = state.lanePools.some((pool) => pool.routes.length > 0) || state.modelRank.length > 0;
|
|
359
595
|
if (!anyConfigured) return "";
|
|
360
|
-
return renderPoolsBlock(state);
|
|
596
|
+
return renderPoolsBlock(state, sessionRouteOf(context));
|
|
361
597
|
},
|
|
362
598
|
});
|
|
363
599
|
|
|
@@ -370,6 +606,13 @@ function applyDispatch(ctx, config) {
|
|
|
370
606
|
// named; model-less calls use the system default and pass through.
|
|
371
607
|
ctx.on("tools/pre-execute", (exec, next) => {
|
|
372
608
|
try {
|
|
609
|
+
if (exec?.name === "list_subagent_models") {
|
|
610
|
+
// Enumeration sanitation: DSH rejects provider-less calls with a
|
|
611
|
+
// bare "`provider` must be non-empty"; deny once with the pool
|
|
612
|
+
// table's provider list instead (fail-open wrapper below).
|
|
613
|
+
const fix = enumerationFixOf(exec.arguments, config);
|
|
614
|
+
return fix === null ? next() : { kind: "deny", reason: fix };
|
|
615
|
+
}
|
|
373
616
|
if (exec?.name !== "subagent" && exec?.name !== "subagent_fork") return next();
|
|
374
617
|
const args = exec.arguments;
|
|
375
618
|
|
|
@@ -380,7 +623,7 @@ function applyDispatch(ctx, config) {
|
|
|
380
623
|
// implicit dispatch, losing the lane's route. Deny with the
|
|
381
624
|
// exact corrected arguments instead — one immediate retry on
|
|
382
625
|
// the intended route.
|
|
383
|
-
const fix = annotationFixOf(args, config);
|
|
626
|
+
const fix = annotationFixOf(args, config) ?? routeFixOf(args, config);
|
|
384
627
|
if (fix !== null) return { kind: "deny", reason: fix };
|
|
385
628
|
|
|
386
629
|
// Enforce gate: unchanged semantics, now sharing the factored
|
package/host/routes.js
CHANGED
|
@@ -95,6 +95,12 @@ const NUMBER_FIELDS = [
|
|
|
95
95
|
"wmReadBudgetTokens",
|
|
96
96
|
"wmSubagentForceTokens",
|
|
97
97
|
];
|
|
98
|
+
/** Watermark numbers with REAL bounds (host/config.js schema): rejected (not
|
|
99
|
+
* clamped) on write, defaulted on read — NUMBER_FIELDS only rejects negatives,
|
|
100
|
+
* which would let a 0 ratio silently disable subagent tier scaling. */
|
|
101
|
+
const WM_BOUNDED_NUMBER_FIELDS = [
|
|
102
|
+
{ field: "wmSubagentWindowRatio", min: 0.1, max: 1, def: 0.75 },
|
|
103
|
+
];
|
|
98
104
|
/** Trim numbers carry their own schema bounds (host/config.js), so the
|
|
99
105
|
* shared NUMBER_FIELDS coercion (which only rejects negatives) is too
|
|
100
106
|
* loose — these validate against each field's declared min/max. This group
|
|
@@ -102,16 +108,16 @@ const NUMBER_FIELDS = [
|
|
|
102
108
|
* but never sent by the client fails every fenced write (and vice versa). */
|
|
103
109
|
const TRIM_NUMBER_FIELDS = [
|
|
104
110
|
{ field: "trimGrowthTokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 22500 },
|
|
105
|
-
{ field: "trimIncrementTokens", min: 1000, max: Number.POSITIVE_INFINITY, def:
|
|
106
|
-
{ field: "trimPressurePercent", min: 5, max: 100, def:
|
|
111
|
+
{ field: "trimIncrementTokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 25000 },
|
|
112
|
+
{ field: "trimPressurePercent", min: 5, max: 100, def: 65 },
|
|
107
113
|
{ field: "trimEmergencyPercent", min: 5, max: 100, def: 95 },
|
|
108
114
|
{ field: "trimWindowTokens", min: 0, max: Number.POSITIVE_INFINITY, def: 0 },
|
|
109
|
-
{ field: "trimProtectedTail", min: 0, max: 100, def:
|
|
110
|
-
{ field: "trimMaxFoldsPerHour", min: 1, max: 60, def: 6 },
|
|
115
|
+
{ field: "trimProtectedTail", min: 0, max: 100, def: 10 },
|
|
111
116
|
{ field: "trimLadderT2Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 8000 },
|
|
112
117
|
{ field: "trimLadderT2MinBlocks", min: 2, max: 200, def: 3 },
|
|
113
118
|
{ field: "trimLadderT3Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 6000 },
|
|
114
119
|
{ field: "trimLadderT3MinBlocks", min: 2, max: 200, def: 2 },
|
|
120
|
+
{ field: "trimMaxFoldsPerHour", min: 1, max: 60, def: 30 },
|
|
115
121
|
];
|
|
116
122
|
const BOOLEAN_FIELDS = [
|
|
117
123
|
"wmAutoHandover",
|
|
@@ -144,10 +150,13 @@ const INTENT_NUMBER_FIELDS = [
|
|
|
144
150
|
{ field: "intentHistoryMaxChars", min: 200, max: 20000, def: 2400 },
|
|
145
151
|
{ field: "intentContextWindow", min: 1024, max: Number.POSITIVE_INFINITY, def: 200000 },
|
|
146
152
|
];
|
|
147
|
-
/** Intent route references
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
const INTENT_ROUTE_FIELDS = ["intentClassifierRoute", "intentFallbackRoute"
|
|
153
|
+
/** Intent route references round-trip the "unset" {provider:"", model:""}
|
|
154
|
+
* pair, so they validate against the loosened cleanIntentRoute (both keys
|
|
155
|
+
* must be strings; either may be empty); intent routes never carry effort. */
|
|
156
|
+
const INTENT_ROUTE_FIELDS = ["intentClassifierRoute", "intentFallbackRoute"];
|
|
157
|
+
/** Trim side routes additionally round-trip an optional effort pin — the
|
|
158
|
+
* settings page's reasoning-effort select — via cleanTrimRoute below. */
|
|
159
|
+
const TRIM_ROUTE_FIELDS = ["trimSideModel", "trimFoldFallbackModel"];
|
|
151
160
|
/** Intent candidate list (route + optional effort/note per entry). */
|
|
152
161
|
const INTENT_CANDIDATE_FIELDS = ["intentCandidates"];
|
|
153
162
|
|
|
@@ -304,6 +313,18 @@ function cleanIntentRoute(entry) {
|
|
|
304
313
|
const provider = typeof entry.provider === "string" ? entry.provider.trim() : null;
|
|
305
314
|
const model = typeof entry.model === "string" ? entry.model.trim() : null;
|
|
306
315
|
return provider === null || model === null ? null : { provider, model };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Coerce one TRIM side route: like cleanIntentRoute, but a non-empty string
|
|
319
|
+
* effort rides along — the trim summarizer pins reasoning effort on its side
|
|
320
|
+
* model, and stripping it here made every settings save silently revert the
|
|
321
|
+
* settings page's effort select to the default. Intent routes omit effort. */
|
|
322
|
+
function cleanTrimRoute(entry) {
|
|
323
|
+
const route = cleanIntentRoute(entry);
|
|
324
|
+
if (route === null) return null;
|
|
325
|
+
const effort = typeof entry.effort === "string" ? entry.effort.trim() : "";
|
|
326
|
+
if (effort !== "") route.effort = effort;
|
|
327
|
+
return route;
|
|
307
328
|
}
|
|
308
329
|
|
|
309
330
|
/** Coerce one intent candidate: provider/model must be non-empty; effort/note
|
|
@@ -349,6 +370,7 @@ export function snapshotOf(config) {
|
|
|
349
370
|
? rawOf(config[field]).map(cleanEffortEntry).filter(Boolean)
|
|
350
371
|
: [];
|
|
351
372
|
for (const field of INTENT_ROUTE_FIELDS) values[field] = cleanIntentRoute(rawOf(config?.[field])) ?? { provider: "", model: "" };
|
|
373
|
+
for (const field of TRIM_ROUTE_FIELDS) values[field] = cleanTrimRoute(rawOf(config?.[field])) ?? { provider: "", model: "" };
|
|
352
374
|
for (const field of INTENT_CANDIDATE_FIELDS)
|
|
353
375
|
values[field] = mergeIntentCandidates(rawOf(config?.[field]));
|
|
354
376
|
values.intentClassifierKind = ENUM_FIELDS.intentClassifierKind.includes(rawOf(config?.intentClassifierKind))
|
|
@@ -372,6 +394,10 @@ export function snapshotOf(config) {
|
|
|
372
394
|
const raw = Number(rawOf(config?.[field]));
|
|
373
395
|
values[field] = Number.isFinite(raw) ? raw : 0;
|
|
374
396
|
}
|
|
397
|
+
for (const { field, def } of WM_BOUNDED_NUMBER_FIELDS) {
|
|
398
|
+
const raw = Number(rawOf(config?.[field]));
|
|
399
|
+
values[field] = Number.isFinite(raw) ? raw : def;
|
|
400
|
+
}
|
|
375
401
|
values.wmDenyMode = ENUM_FIELDS.wmDenyMode.includes(rawOf(config?.wmDenyMode))
|
|
376
402
|
? rawOf(config.wmDenyMode)
|
|
377
403
|
: "cap";
|
|
@@ -419,6 +445,11 @@ export function coerceValues(input) {
|
|
|
419
445
|
if (route === null) return null;
|
|
420
446
|
values[field] = route;
|
|
421
447
|
}
|
|
448
|
+
for (const field of TRIM_ROUTE_FIELDS) {
|
|
449
|
+
const route = cleanTrimRoute(input[field]);
|
|
450
|
+
if (route === null) return null;
|
|
451
|
+
values[field] = route;
|
|
452
|
+
}
|
|
422
453
|
for (const field of INTENT_CANDIDATE_FIELDS) {
|
|
423
454
|
if (!Array.isArray(input[field])) return null;
|
|
424
455
|
const candidates = input[field].map(cleanIntentCandidate);
|
|
@@ -454,6 +485,11 @@ export function coerceValues(input) {
|
|
|
454
485
|
if (!Number.isFinite(raw) || raw < 0) return null;
|
|
455
486
|
values[field] = raw;
|
|
456
487
|
}
|
|
488
|
+
for (const { field, min, max } of WM_BOUNDED_NUMBER_FIELDS) {
|
|
489
|
+
const raw = Number(input[field]);
|
|
490
|
+
if (!Number.isFinite(raw) || raw < min || raw > max) return null;
|
|
491
|
+
values[field] = raw;
|
|
492
|
+
}
|
|
457
493
|
if (!ENUM_FIELDS.wmDenyMode.includes(input.wmDenyMode)) return null;
|
|
458
494
|
values.wmDenyMode = input.wmDenyMode;
|
|
459
495
|
for (const field of BOOLEAN_FIELDS) {
|