dsh-switchman 1.8.0 → 1.8.1

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.
Files changed (2) hide show
  1. package/host/dispatch.js +132 -20
  2. package/package.json +1 -1
package/host/dispatch.js CHANGED
@@ -7,6 +7,16 @@
7
7
  * `tools/pre-execute` waterfall listener denies `subagent` calls that name
8
8
  * a provider+model outside every configured pool and modelRank.
9
9
  *
10
+ * The same listener also runs an annotation sanitation pass in EVERY mode:
11
+ * a model id copied verbatim off the rendered table can embed its
12
+ * display-only `(TIER)`/`@effort` annotations (the recommendation table is
13
+ * the one string an LLM is most tempted to copy), which DSH's exact-match
14
+ * child-route whitelist then rejects with a generic binding error that
15
+ * pushes the caller back to implicit dispatch — silently losing the lane's
16
+ * route. The sanitation denies such calls with the exact corrected
17
+ * arguments instead (exec.arguments is deep-frozen by the runtime, so a
18
+ * deny is the only correction channel `tools/pre-execute` offers).
19
+ *
10
20
  * Model-facing text is English and byte-stable for a given configuration
11
21
  * (the anchor line never varies with UI locale). All reads go through the
12
22
  * live volatile references, so settings edits apply without a plugin
@@ -177,6 +187,103 @@ function effortMapOf(pool) {
177
187
  return effortOf;
178
188
  }
179
189
 
190
+ /** Trailing display annotations renderPoolsBlock appends to a pool entry:
191
+ * the `(S)/(A)/(B)/(C)` capability-grade parenthetical (uppercase only —
192
+ * that is what renderRoute emits), the `@effort` pin, and the
193
+ * `⚠not-authorized` drift marker. */
194
+ const TRAILING_EFFORT = /@([A-Za-z0-9._-]+)$/;
195
+ const TRAILING_TIER = /\([SABC]\)$/;
196
+ const TRAILING_MARKER = /\s*⚠not-authorized$/u;
197
+
198
+ /** Strip every trailing display annotation from a model id
199
+ * ("m(A)@high" → "m"), looped so any order reduces; an unannotated id
200
+ * round-trips unchanged. */
201
+ function stripTrailingAnnotations(model) {
202
+ let s = model.trim();
203
+ for (;;) {
204
+ const next = s.replace(TRAILING_MARKER, "").replace(TRAILING_EFFORT, "").replace(TRAILING_TIER, "").trim();
205
+ if (next === s) return s;
206
+ s = next;
207
+ }
208
+ }
209
+
210
+ /** Folded-key → first configured {provider, model} route over every lane
211
+ * and modelRank — the enforce gate's matcher, factored out so the
212
+ * sanitation pass shares it. Empty when nothing is configured. */
213
+ function configuredRoutes(config) {
214
+ const routes = new Map();
215
+ const add = (route) => {
216
+ if (!isModelRoute(route)) return;
217
+ const key = normalizeModelKey(route.provider, route.model);
218
+ if (key !== "" && !routes.has(key)) routes.set(key, { provider: route.provider, model: route.model });
219
+ };
220
+ for (const lane of LANES) for (const route of readList(config[laneField(lane)])) add(route);
221
+ for (const entry of readList(config.modelRank)) add(entry);
222
+ return routes;
223
+ }
224
+
225
+ /** First effort pin configured for one folded route key, across every
226
+ * lane's effort list (null when the route carries no pin) — completes a
227
+ * corrective hint whose copied id kept only the (TIER) annotation. */
228
+ function configuredEffortPin(config, key) {
229
+ for (const lane of LANES) {
230
+ for (const entry of readList(config[laneEffortsField(lane)])) {
231
+ if (!isEffortEntry(entry)) continue;
232
+ if (normalizeModelKey(entry.provider, entry.model) === key) return entry.effort;
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+
238
+ /** Corrective deny reason for one delegation call whose model argument
239
+ * embeds pool-table display annotations, or null to pass through. The
240
+ * stripped id must match a configured route (folded, like the enforce
241
+ * gate) — an unknown id is left to DSH's own route validation, so an
242
+ * exotic-but-real id that merely looks annotated never gets denied.
243
+ * Covers the plain suffix copy ("m(A)", "m(A)@high"), the whole-cell copy
244
+ * including the ⚠not-authorized drift marker, and the provider-combined
245
+ * copy ("provider/m(A)" with provider left empty). The reasoning_effort
246
+ * hint rides along when the copied string carried the @effort annotation
247
+ * or the matched route holds a configured pin, and the call did not
248
+ * already set reasoning_effort itself. */
249
+ function annotationFixOf(args, config) {
250
+ const model = typeof args?.model === "string" ? args.model.trim() : "";
251
+ if (model === "") return null;
252
+ const providerGiven = typeof args?.provider === "string" && args.provider.trim() !== "";
253
+ let provider = providerGiven ? args.provider.trim() : "";
254
+ let modelPart = model;
255
+ if (!providerGiven && modelPart.includes("/")) {
256
+ const cut = modelPart.lastIndexOf("/");
257
+ provider = modelPart.slice(0, cut).trim();
258
+ modelPart = modelPart.slice(cut + 1).trim();
259
+ }
260
+ modelPart = modelPart.replace(TRAILING_MARKER, "").trim();
261
+ const effortMatch = TRAILING_EFFORT.exec(modelPart);
262
+ const stripped = stripTrailingAnnotations(modelPart);
263
+ if (stripped === modelPart || stripped === "") return null;
264
+ const key = normalizeModelKey(provider, stripped);
265
+ const matched = key === "" ? undefined : configuredRoutes(config).get(key);
266
+ if (matched === undefined) return null;
267
+ // The re-dispatch must name BOTH fields whenever the original call left
268
+ // provider empty (provider+model go together on these tools), even when
269
+ // the provider was recoverable from a combined "provider/model(…)" copy.
270
+ // A provider-given whole-cell copy still carries its "provider/" prefix
271
+ // inside the stripped id — the matched route's clean model is the only
272
+ // suggestion DSH's exact-match whitelist will actually accept.
273
+ const modelFix = stripped.includes("/") ? matched.model : stripped;
274
+ const fields = providerGiven
275
+ ? `model "${modelFix}"`
276
+ : `provider "${provider !== "" ? provider : matched.provider}", model "${modelFix}"`;
277
+ const hasExplicitEffort = typeof args?.reasoning_effort === "string" && args.reasoning_effort.trim() !== "";
278
+ const pinned = effortMatch !== null ? effortMatch[1] : configuredEffortPin(config, key);
279
+ const effort = hasExplicitEffort || pinned === null ? "" : ` and reasoning_effort "${pinned}"`;
280
+ return (
281
+ `[SWITCHMAN:POOLS] the model argument "${args.model}" embeds pool-table display annotations — ` +
282
+ `the (S/A/B/C) capability grade and any @effort suffix are annotations, NOT parts of the model id. ` +
283
+ `Re-dispatch this call with ${fields}${effort}, keeping every other argument unchanged.`
284
+ );
285
+ }
286
+
180
287
  /** The `[SWITCHMAN:POOLS]` block: anchor line, one line per configured lane,
181
288
  * then the guidance lines (byte-stable for a given configuration). Routes
182
289
  * outside the session's DSH-authorized child models carry a ⚠not-authorized
@@ -207,7 +314,8 @@ function renderPoolsBlock(state) {
207
314
  `When delegating via subagent/workflow, pick a model from the lane matching the task category; spawn_teammate takes no model argument (lanes do not apply to it).`,
208
315
  `Lanes marked * are in manual order: dispatch follows the listed order exactly (the head is the first choice).`,
209
316
  `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.`,
210
- `A route suffixed @effort has a manually pinned reasoning effort — pass it as reasoning_effort when delegating to that model (it overrides the lane default).`,
317
+ `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 before dispatching: 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.`,
211
319
  `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.`,
212
320
  `Explicit provider/model/effort on subagent/workflow calls is validated against this session's DSH-authorized child models (DSH Settings: allow agents to choose subagent models) — NOT against this table; when denied, discover allowed routes via list_subagent_models, or dispatch implicitly by omitting all three fields (the child then uses configured defaults or inherits the parent route).`,
213
321
  );
@@ -253,34 +361,38 @@ function applyDispatch(ctx, config) {
253
361
  },
254
362
  });
255
363
 
256
- // Enforce gate (tools/pre-execute waterfall): deny decisions are returned
257
- // directly WITHOUT calling next() — { kind: "deny", reason } — while every
258
- // other path must call and return next(). Only subagent calls that name
259
- // both provider and model are checked; model-less calls use the system
260
- // default and pass through untouched.
364
+ // Annotation sanitation (every mode) + enforce gate (enforce mode only).
365
+ // Both are tools/pre-execute waterfall listeners in one registration:
366
+ // deny decisions are returned directly WITHOUT calling next() —
367
+ // { kind: "deny", reason } — while every other path must call and
368
+ // return next(). The sanitation covers subagent and subagent_fork; the
369
+ // enforce gate below stays subagent-only with both provider and model
370
+ // named; model-less calls use the system default and pass through.
261
371
  ctx.on("tools/pre-execute", (exec, next) => {
262
372
  try {
373
+ if (exec?.name !== "subagent" && exec?.name !== "subagent_fork") return next();
374
+ const args = exec.arguments;
375
+
376
+ // Annotation sanitation: a model id copied verbatim off the
377
+ // rendered table embeds its display-only (TIER)/@effort/⚠
378
+ // annotations; DSH's exact-match whitelist would reject it with
379
+ // a generic binding error that pushes the caller back to
380
+ // implicit dispatch, losing the lane's route. Deny with the
381
+ // exact corrected arguments instead — one immediate retry on
382
+ // the intended route.
383
+ const fix = annotationFixOf(args, config);
384
+ if (fix !== null) return { kind: "deny", reason: fix };
385
+
386
+ // Enforce gate: unchanged semantics, now sharing the factored
387
+ // configuredRoutes matcher (same folded keys as before).
263
388
  if (readField(config.dispatchEnforce) !== "enforce") return next();
264
389
  if (exec?.name !== "subagent") return next();
265
- const args = exec.arguments;
266
390
  if (typeof args?.provider !== "string" || args.provider === "") return next();
267
391
  if (typeof args?.model !== "string" || args.model === "") return next();
268
392
  const key = normalizeModelKey(args.provider, args.model);
269
393
  if (key === "") return next();
270
394
 
271
- const allowed = new Set();
272
- for (const lane of LANES) {
273
- for (const route of readList(config[laneField(lane)])) {
274
- if (!isModelRoute(route)) continue;
275
- const routeKey = normalizeModelKey(route.provider, route.model);
276
- if (routeKey !== "") allowed.add(routeKey);
277
- }
278
- }
279
- for (const entry of readList(config.modelRank)) {
280
- if (!isModelRoute(entry)) continue;
281
- const rankKey = normalizeModelKey(entry.provider, entry.model);
282
- if (rankKey !== "") allowed.add(rankKey);
283
- }
395
+ const allowed = configuredRoutes(config);
284
396
  if (allowed.size === 0 || allowed.has(key)) return next();
285
397
 
286
398
  const state = readDispatchState(ctx, config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-switchman",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "Autonomous Agent Teams doctrine for the four shipped DeepSeek Harness presets, plus language preferences, dispatch pools with model ranking, and context watermark control.",
5
5
  "license": "MIT",
6
6
  "keywords": [