dsh-switchman 1.11.3 → 1.11.5

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.
@@ -695,10 +695,9 @@ export function applyContextTrim(ctx, config) {
695
695
  } else if (view.applied > 0) {
696
696
  epochResetDoneBySession.delete(sessionId);
697
697
  }
698
- // Capture the session's own resolved reasoningEffort alongside
699
- // provider/model: at stream-listener time DSH has already resolved the
700
- // effective effort for this dispatch, so the side route can inherit it
701
- // verbatim instead of re-deriving (and losing) it.
698
+ // Record the active request's route and resolved effort for diagnostics only.
699
+ // The side summarizer follows provider/model only and resolves effort from its
700
+ // model metadata rather than inheriting this dispatch's effort.
702
701
  const model = {
703
702
  provider: options.provider ?? null,
704
703
  model: options.model ?? null,
@@ -718,16 +717,14 @@ export function applyContextTrim(ctx, config) {
718
717
  }
719
718
 
720
719
  /** Resolve the side-model route: explicit trimSideModel route, else the
721
- * session's own model (inherit — design §5). */
720
+ * session's own provider/model. Effort is resolved from that model's
721
+ * supported thinking metadata, not inherited from the active session call. */
722
722
  function sideRouteFor(sessionId) {
723
723
  const explicit = readRoute(config, "trimSideModel");
724
724
  if (explicit !== null) return explicit;
725
725
  const model = metrics.of(sessionId)?.lastModel ?? null;
726
726
  if (model !== null && typeof model.provider === "string" && model.provider !== "" && typeof model.model === "string" && model.model !== "") {
727
- // Inherit the session's own resolved effort when one was captured.
728
- return typeof model.effort === "string" && model.effort !== ""
729
- ? { provider: model.provider, model: model.model, effort: model.effort }
730
- : { provider: model.provider, model: model.model };
727
+ return { provider: model.provider, model: model.model };
731
728
  }
732
729
  return null;
733
730
  }
@@ -224,15 +224,25 @@ function describeFailure(chunk) {
224
224
  return ` (${raw.replace(/\s+/gu, " ").trim().slice(0, 300)})`;
225
225
  }
226
226
 
227
- /** Side-model summary. llm: the dsh llm service (receiver-bound methods);
228
- * route: { provider, model, effort? } | null (null → caller skips); materialText:
229
- * compression prompt (the ladder distillation passes its own); deadlineMs:
230
- * hard budget. Returns the summary text, or null on ANY failure (caller
231
- * skips the fold cycle). */
227
+ /** Choose the lowest enabled tier when several reasoning levels are available.
228
+ * A model with only an on/off switch is pinned to "off"; when reasoning
229
+ * metadata is absent, leave the model's adapter default unchanged. */
230
+ export function recommendedReasoningEffort(modelInfo) {
231
+ const efforts = modelInfo?.reasoning?.efforts;
232
+ if (!Array.isArray(efforts) || efforts.length === 0) return null;
233
+ const ids = efforts.map((entry) => typeof entry === "string" ? entry : entry?.id).filter((id) => typeof id === "string" && id !== "");
234
+ const enabled = ids.filter((id) => id !== "off");
235
+ if (enabled.length > 1) return enabled[0];
236
+ return ids.includes("off") ? "off" : null;
237
+ }
238
+
239
+ /** Side-model summary. llm: the dsh llm service; route: {provider, model, effort?}.
240
+ * An empty effort pin is resolved from model reasoning metadata; unavailable
241
+ * metadata leaves the adapter default unchanged. Returns summary text or null. */
232
242
  export async function summarizeWithModel(llm, route, materialText, options = {}) {
233
243
  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.
244
+ // Effort-retry notice: fires before a one-time effort fallback, whether
245
+ // or not that retry lands. Falls back to fail when no notice is supplied.
236
246
  const notice = typeof options.onNotice === "function" ? options.onNotice : null;
237
247
  if (route === null || route === undefined) { fail?.("no-route"); return null; }
238
248
  if (typeof llm?.prepareCall !== "function") { fail?.("no-prepare-call"); return null; }
@@ -242,39 +252,40 @@ export async function summarizeWithModel(llm, route, materialText, options = {})
242
252
  const controller = new AbortController();
243
253
  const timer = setTimeout(() => controller.abort(new Error("trim summary deadline")), deadlineMs);
244
254
  try {
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";
255
+ // Resolve from the selected model itself: an empty pin means the lowest
256
+ // advertised thinking effort, while absent reasoning metadata leaves
257
+ // the adapter default (no thinking) untouched. Explicit effort pins win.
258
+ let modelInfo = null;
259
+ if (typeof llm.resolveModelInfo === "function") {
260
+ try { modelInfo = await llm.resolveModelInfo(route.provider, route.model, controller.signal); }
261
+ catch { /* Metadata is advisory; prepareCall remains authoritative. */ }
262
+ }
263
+ const automaticEffort = recommendedReasoningEffort(modelInfo);
264
+ const hasOffEffort = Array.isArray(modelInfo?.reasoning?.efforts) && modelInfo.reasoning.efforts.some((entry) => (typeof entry === "string" ? entry : entry?.id) === "off");
265
+ const explicitEffort = typeof route.effort === "string" && route.effort !== "";
266
+ const requestedEffort = explicitEffort ? route.effort : automaticEffort;
267
+ const proposalFor = (effort) => ({
268
+ provider: route.provider,
269
+ model: route.model,
270
+ maxTokens,
271
+ ...(typeof effort === "string" && effort !== "" ? { reasoningEffort: effort } : {}),
272
+ });
258
273
  let call;
259
274
  try {
260
- call = await llm.prepareCall(
261
- { provider: route.provider, model: route.model, maxTokens, reasoningEffort: requestedEffort },
262
- controller.signal,
263
- (controls) => controls,
264
- );
275
+ call = await llm.prepareCall(proposalFor(requestedEffort), controller.signal, (controls) => controls);
265
276
  } 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
277
  const code = prepareError?.code ?? null;
270
278
  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
- );
279
+ if (code !== "UNSUPPORTED_REASONING_EFFORT" && !named) throw prepareError;
280
+ // Stale metadata or a stale pin should not prevent a summary: retry
281
+ // once at the model's automatic minimum, or without an effort. Never
282
+ // turn an explicitly requested "off" into enabled reasoning.
283
+ const retryEffort = requestedEffort === "off"
284
+ ? null
285
+ : (explicitEffort && automaticEffort !== requestedEffort ? automaticEffort : (hasOffEffort ? "off" : null));
286
+ if (retryEffort === requestedEffort) throw prepareError;
287
+ (notice ?? fail)?.(`effort "${requestedEffort ?? "default"}" unsupported, retrying ${retryEffort ?? "without reasoning"}`);
288
+ call = await llm.prepareCall(proposalFor(retryEffort), controller.signal, (controls) => controls);
278
289
  }
279
290
  let text = "";
280
291
  let finishKind = null;
package/host/ptc-guard.js CHANGED
@@ -89,6 +89,12 @@ function renderPtcGuard() {
89
89
  `- Every other tool MUST be invoked as an awaited SDK function inside a ${RUN_CODE_NAME} program: \`await tools.name(args)\` — e.g. \`await tools.skill({ name: "git-commit-message" })\` or \`await tools.spawn_teammate({ ... })\`.`,
90
90
  `- A direct call fails with: unknown tool "x": only \`${RUN_CODE_NAME}\` is callable directly — call \`x\` from inside a \`${RUN_CODE_NAME}\` program instead. Never retry the direct form; wrap the call.`,
91
91
  `- Wherever this prompt says "call the X tool" (skill catalog, doctrines, per-turn reminders), read it as "call X inside a ${RUN_CODE_NAME} program". Batch independent calls as parallel awaits inside ONE program.`,
92
+ `- \`grep.pattern\` is a regular expression. In a normal JavaScript string inside \`${RUN_CODE_NAME}\`, double backslashes: write \`"\\\\("\` to pass \`\\(\` to grep; \`String.raw\` tagged templates also preserve backslashes. If grep reports \`unclosed group\`, fix the pattern before retrying; never repeat it unchanged.`,
93
+ `- Unless this call must intentionally retain state across calls (e.g. the one-time CUA bridge initialization), MUST wrap the entire ${RUN_CODE_NAME} program body in an async IIFE: \`await (async () => { ... })();\`. Keep all temporary \`const\`/\`let\`/\`class\` declarations inside it; this makes reusing local names such as \`res\` safe even when an earlier call used them. Top-level bindings persist for this Agent: only create them for intentionally shared state, and never redeclare existing top-level \`const\`/\`let\`/\`class\`; reassign an existing top-level \`var\` or choose a fresh unique name. If \`Identifier ... has already been declared\` occurs, rewrite the program in the IIFE rather than merely rerunning it—the SyntaxError happens before the body runs.`,
94
+ `- For intentional top-level bindings, maintain a ledger of declared names at \`globalThis.__dshSwitchmanPtcBindings\` (initialize idempotently with \`globalThis.__dshSwitchmanPtcBindings ??= new Set()\`). Before emitting a program with top-level declarations, check each name against this ledger and the visible prior \`${RUN_CODE_NAME}\` snippets; after a successful declaration, add the name with \`globalThis.__dshSwitchmanPtcBindings.add("name")\`. This ledger is manually maintained, not automatic introspection: if the old history or ledger is incomplete, do not guess—keep the code in an IIFE or choose a fresh name.`,
95
+ `- For routine \`${RUN_CODE_NAME}\` calls — including programs that only call SDK tools such as \`tools.skill(...)\` — pass only \`code\`, \`description\`, and \`timeoutMs\`; omit both \`sandbox_permissions\` and \`justification\`. If a call is rejected for invalid justification, never repeat it unchanged; retry without those fields.`,
96
+ `- If ${RUN_CODE_NAME} returns a parser error such as "Expected ident", treat it as a syntax issue in the submitted code; when present, the appended "File sandbox: ..." reports execution context, not the cause. Correct the code before retrying; never repeat it unchanged.`,
97
+ `- When a ${RUN_CODE_NAME} program embeds long prompt text, do not hand-write it as a double-quoted JavaScript string if it contains quotes (for example, getResource(".") or System.getProperty("user.dir")): build the text as data, escape the inner quotes, or serialize it with JSON.stringify(text); inspect or syntax-check the complete source before retrying.`,
92
98
  `- Never set \`sandbox_permissions\` on any call, direct or SDK: it is a privilege-escalation request and fails with "not strictly wider than this call's current ... mode" whenever the session already runs at least that access level — retry without the field.`,
93
99
  ].join("\n");
94
100
  }
package/host/routes.js CHANGED
@@ -150,13 +150,12 @@ const INTENT_NUMBER_FIELDS = [
150
150
  { field: "intentHistoryMaxChars", min: 200, max: 20000, def: 2400 },
151
151
  { field: "intentContextWindow", min: 1024, max: Number.POSITIVE_INFINITY, def: 200000 },
152
152
  ];
153
- /** Intent route references round-trip the "unset" {provider:"", model:""}
153
+ /** Intent classifier routes round-trip the "unset" {provider:"", model:""}
154
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"];
155
+ * must be strings; either may be empty). */
156
+ const INTENT_ROUTE_FIELDS = ["intentClassifierRoute"];
157
+ /** Summary routes and the intent fallback route can carry an effort pin. */
158
+ const EFFORT_ROUTE_FIELDS = ["trimSideModel", "trimFoldFallbackModel", "intentFallbackRoute"];
160
159
  /** Intent candidate list (route + optional effort/note per entry). */
161
160
  const INTENT_CANDIDATE_FIELDS = ["intentCandidates"];
162
161
 
@@ -315,11 +314,8 @@ function cleanIntentRoute(entry) {
315
314
  return provider === null || model === null ? null : { provider, model };
316
315
  }
317
316
 
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) {
317
+ /** Coerce one route that may carry an optional reasoning-effort pin. */
318
+ function cleanEffortRoute(entry) {
323
319
  const route = cleanIntentRoute(entry);
324
320
  if (route === null) return null;
325
321
  const effort = typeof entry.effort === "string" ? entry.effort.trim() : "";
@@ -370,7 +366,7 @@ export function snapshotOf(config) {
370
366
  ? rawOf(config[field]).map(cleanEffortEntry).filter(Boolean)
371
367
  : [];
372
368
  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: "" };
369
+ for (const field of EFFORT_ROUTE_FIELDS) values[field] = cleanEffortRoute(rawOf(config?.[field])) ?? { provider: "", model: "" };
374
370
  for (const field of INTENT_CANDIDATE_FIELDS)
375
371
  values[field] = mergeIntentCandidates(rawOf(config?.[field]));
376
372
  values.intentClassifierKind = ENUM_FIELDS.intentClassifierKind.includes(rawOf(config?.intentClassifierKind))
@@ -445,8 +441,8 @@ export function coerceValues(input) {
445
441
  if (route === null) return null;
446
442
  values[field] = route;
447
443
  }
448
- for (const field of TRIM_ROUTE_FIELDS) {
449
- const route = cleanTrimRoute(input[field]);
444
+ for (const field of EFFORT_ROUTE_FIELDS) {
445
+ const route = cleanEffortRoute(input[field]);
450
446
  if (route === null) return null;
451
447
  values[field] = route;
452
448
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-switchman",
3
- "version": "1.11.3",
3
+ "version": "1.11.5",
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": [