dsh-switchman 1.11.1 → 1.11.3
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.md +2 -2
- package/client.js +71 -4
- package/cordis.patch.yml +29 -0
- package/host/config.js +13 -1
- package/host/context-trim/index.js +38 -3
- package/host/context-trim/metrics.js +2 -4
- package/host/context-trim/summarizer.js +38 -7
- package/host/context-watch.js +80 -7
- package/host/dispatch.js +221 -3
- package/host/ptc-guard.js +114 -0
- package/host/routes.js +40 -4
- package/host/teams.js +3 -1
- package/host/tool-args-sanitize.js +106 -0
- package/index.js +22 -1
- package/package.json +1 -1
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,6 +291,208 @@ 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
|
+
|
|
287
496
|
/** The assembling session's own route, from the DSH agent-loop's assemble
|
|
288
497
|
* context (`agent.ts` passes `modelSelection` with every turn assembly;
|
|
289
498
|
* older builds and non-agent assemblies omit it). Null when absent or
|
|
@@ -340,9 +549,11 @@ function renderPoolsBlock(state, sessionRoute = null) {
|
|
|
340
549
|
`Lanes marked * are in manual order: dispatch follows the listed order exactly (the head is the first choice).`,
|
|
341
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.`,
|
|
342
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.`,
|
|
343
|
-
`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.`,
|
|
344
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.`,
|
|
345
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).`,
|
|
346
557
|
);
|
|
347
558
|
if (anyUnauthorized) {
|
|
348
559
|
lines.push(
|
|
@@ -395,6 +606,13 @@ function applyDispatch(ctx, config) {
|
|
|
395
606
|
// named; model-less calls use the system default and pass through.
|
|
396
607
|
ctx.on("tools/pre-execute", (exec, next) => {
|
|
397
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
|
+
}
|
|
398
616
|
if (exec?.name !== "subagent" && exec?.name !== "subagent_fork") return next();
|
|
399
617
|
const args = exec.arguments;
|
|
400
618
|
|
|
@@ -405,7 +623,7 @@ function applyDispatch(ctx, config) {
|
|
|
405
623
|
// implicit dispatch, losing the lane's route. Deny with the
|
|
406
624
|
// exact corrected arguments instead — one immediate retry on
|
|
407
625
|
// the intended route.
|
|
408
|
-
const fix = annotationFixOf(args, config);
|
|
626
|
+
const fix = annotationFixOf(args, config) ?? routeFixOf(args, config);
|
|
409
627
|
if (fix !== null) return { kind: "deny", reason: fix };
|
|
410
628
|
|
|
411
629
|
// Enforce gate: unchanged semantics, now sharing the factored
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** dsh-switchman PTC direct-call guard layer.
|
|
2
|
+
*
|
|
3
|
+
* Symptom (observed 2026-11-12, session-level): in PTC sessions the model
|
|
4
|
+
* calls a named tool DIRECTLY — e.g. `spawn_teammate` or `skill` after the
|
|
5
|
+
* teams doctrine or the skill catalog names it — and the harness rejects the
|
|
6
|
+
* whole call with `unknown tool "x": only \`run_code\` is callable directly`.
|
|
7
|
+
* The PTC executor's own `PTC_ONLY` section (order 800) states the rule once
|
|
8
|
+
* near the top, but every later doctrine that names tools directly erodes it.
|
|
9
|
+
*
|
|
10
|
+
* Second symptom (same day, session 22ced69f): the model sets `sandbox_permissions`
|
|
11
|
+
* on a call while the session already runs `danger-full-access`; the escalation
|
|
12
|
+
* request is rejected outright with `sandbox escalation to "workspace-write" is
|
|
13
|
+
* not strictly wider than this call's current "danger-full-access" mode` — an
|
|
14
|
+
* error with no correction hint. The clause below also states the never-set
|
|
15
|
+
* default and the retry-without-the-field fix.
|
|
16
|
+
*
|
|
17
|
+
* Fix: one extra dynamic systemPrompt section (order 10510 — AFTER every
|
|
18
|
+
* other dsh-switchman section: teams 10250, lang 10300, dispatch 10400,
|
|
19
|
+
* context-trim 10450, context-watch 10500) that re-states the transport rule
|
|
20
|
+
* as the LAST word whenever the viewing scope actually resolves to the PTC
|
|
21
|
+
* executor. Native scopes render an empty string (status quo).
|
|
22
|
+
*
|
|
23
|
+
* Detection mirrors the ToolsService's own per-scope resolution: the section
|
|
24
|
+
* text closure receives the assemble context, whose `scope` is the viewing
|
|
25
|
+
* Agent/preset scope key; `ctx.tools.selectExecutor(scope)` (TS-private but
|
|
26
|
+
* present at runtime — verified in the packaged app, `selectExecutor` survives
|
|
27
|
+
* bundling) resolves the nearest scoped executor or the deployment default.
|
|
28
|
+
* The executor is then discriminated BEHAVIORALLY via its public `describe()`:
|
|
29
|
+
* the PTC executor declares `knownNames: ["run_code"]` exactly; the native
|
|
30
|
+
* executor returns no `knownNames` at all. No class-name sniffing, no config
|
|
31
|
+
* reads, no false positives for other executor implementations (they simply
|
|
32
|
+
* get no guard).
|
|
33
|
+
*
|
|
34
|
+
* Fail-open everywhere: a missing/renamed private API, a throwing describe(),
|
|
35
|
+
* or an unavailable executor selection all render "" — the session keeps its
|
|
36
|
+
* current behavior and only loses the mitigation. Zero new dependencies.
|
|
37
|
+
*
|
|
38
|
+
* The clause is English, byte-stable protocol text (same convention as the
|
|
39
|
+
* lang layer) with a `[SWITCHMAN:PTC]` anchor, `interpolate: false` because
|
|
40
|
+
* the text contains braces.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** Reserved transport-tool name the PTC executor declares as its only direct call. */
|
|
44
|
+
const RUN_CODE_NAME = "run_code";
|
|
45
|
+
|
|
46
|
+
/** Final-say section position: after context-watch (10500), the last existing section. */
|
|
47
|
+
const PTC_GUARD_ORDER = 10510;
|
|
48
|
+
|
|
49
|
+
/** True when an executor description declares exactly the reserved run_code transport. */
|
|
50
|
+
function isPtcDescription(description) {
|
|
51
|
+
if (description === null || typeof description !== "object") return false;
|
|
52
|
+
const known = description.knownNames;
|
|
53
|
+
return Array.isArray(known) && known.length === 1 && known[0] === RUN_CODE_NAME;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Selected executor for one assemble scope, or null when the private probe
|
|
57
|
+
* is unusable (missing service, missing/renamed method, unavailable selection). */
|
|
58
|
+
function selectedExecutor(ctx, scope) {
|
|
59
|
+
const tools = ctx?.tools;
|
|
60
|
+
if (tools === null || typeof tools !== "object") return null;
|
|
61
|
+
if (typeof tools.selectExecutor !== "function") return null;
|
|
62
|
+
try {
|
|
63
|
+
const executor = tools.selectExecutor(scope);
|
|
64
|
+
return executor === null || typeof executor !== "object" ? null : executor;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when the scope's selected executor is the PTC executor. `describe([])`
|
|
71
|
+
* is safe for every known executor: the PTC implementation lazily creates its
|
|
72
|
+
* transport there (the ToolsService calls it on every model call anyway) and
|
|
73
|
+
* the native one maps over the empty list. */
|
|
74
|
+
function isPtcScope(ctx, scope) {
|
|
75
|
+
const executor = selectedExecutor(ctx, scope);
|
|
76
|
+
if (executor === null || typeof executor.describe !== "function") return false;
|
|
77
|
+
try {
|
|
78
|
+
return isPtcDescription(executor.describe([]));
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Byte-stable protocol clause. The SDK call shape `await tools.name(args)` is
|
|
85
|
+
* quoted verbatim from the run_code tool's own model-facing description. */
|
|
86
|
+
function renderPtcGuard() {
|
|
87
|
+
return [
|
|
88
|
+
`[SWITCHMAN:PTC] This session runs in PTC (programmatic tool call) mode: \`${RUN_CODE_NAME}\` is the ONLY tool callable directly.`,
|
|
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
|
+
`- 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
|
+
`- 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
|
+
`- 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
|
+
].join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Mount the PTC guard section. Section registration itself relies on the
|
|
97
|
+
* injected systemPrompt service (same as every other layer); the text closure
|
|
98
|
+
* is fully defensive so an assembly in an unexpected shape renders "". */
|
|
99
|
+
function applyPtcGuard(ctx) {
|
|
100
|
+
ctx.systemPrompt.section({
|
|
101
|
+
name: "switchman:ptc-guard",
|
|
102
|
+
order: PTC_GUARD_ORDER,
|
|
103
|
+
interpolate: false,
|
|
104
|
+
text: (context) => {
|
|
105
|
+
try {
|
|
106
|
+
return isPtcScope(ctx, context?.scope) ? renderPtcGuard() : "";
|
|
107
|
+
} catch {
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { applyPtcGuard, isPtcDescription, isPtcScope, renderPtcGuard, selectedExecutor };
|
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
|
|
@@ -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) {
|
package/host/teams.js
CHANGED
|
@@ -82,9 +82,11 @@ const TEAMS_SECTION_TEXT = [
|
|
|
82
82
|
"系统提示词前文的默认团队策略写的是「仅当用户明确要求时才创建 teammate」。该默认策略在当前 profile 下不适用:用户安装 dsh-switchman 即预先授权,你应当自主判断、自主触发,无需再询问用户。",
|
|
83
83
|
"",
|
|
84
84
|
"## 五、团队触发与协作纪律",
|
|
85
|
-
"-
|
|
85
|
+
"- 派发通道优先级(取代前文「一次性、无需共享状态的单点调查用 subagent」的默认分工):本会话工具目录里有 spawn_teammate/list_agents(Agent Teams 已启用)时,一切子任务委派默认走 Agent Teams——spawn_teammate 组队、team_task_* 落共享任务板、send_message/wait_agent 协作;subagent/subagent_fork 单智能体派发退为后备通道,仅在本条下一条所述的不可用场景使用。",
|
|
86
|
+
"- teams 不可用即降级,不重试:工具目录没有 spawn_teammate/list_agents(bundle 未生效或本会话不支持),或 spawn_teammate 报 provisioning failed / 创建失败时,直接改用 subagent/subagent_fork 派发并继续工作,不要反复重试 teams 通道。你自身是 teammate(非 Lead)时维持原规则:不 spawn_teammate,需要子委派时用 subagent。workflow 仅在用户明确要求工作流或脚本化大规模编排时使用,默认不主动使用。",
|
|
86
87
|
"- 写作域隔离:共享任务上写明互不重叠的 write_scopes;用 team_task_* 维护任务板与依赖顺序;Lead 汇总最终 diff、复核关键改动并运行测试;用 wait_agent 等到必需的 teammate 完成,绝不在结果未回前下最终结论。",
|
|
87
88
|
"- 子智能体消息通道:subagent/workflow 返回的子会话 id 只能经 child_message 桥接送达——send_message 只解析 teammate 名字,对子会话 id 必报 active teammate「…」not found;该报错不代表子会话死亡,先改用 child_message,不要据此重派或判定死亡。",
|
|
89
|
+
"- run_code 并行隔离:spawn_teammate / send_message 这类会改变团队环境(成员目录、工具表)的调用不要与文件/搜索工具放进同一 run_code 程序里 Promise.all 并行——并发窗口内其它工具绑定可能误报 unknown tool「…」(UNKNOWN_TOOL);让这类调用单独占一步执行。某工具此前可用却突然报 UNKNOWN_TOOL 时,下一步原样重试即可,不要换工具名或放弃任务。",
|
|
88
90
|
].join("\n");
|
|
89
91
|
|
|
90
92
|
// ---- shared volatile-read helpers (mirrors dispatch.js) --------------------
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Transparent tool-argument sanitation layer (`tools/pre-execute`).
|
|
2
|
+
*
|
|
3
|
+
* Production session logs (4 days, 5961 tool calls, 300 failures) show a
|
|
4
|
+
* recurring failure class where the model emits a composite argument in the
|
|
5
|
+
* wrong JSON shape and the tool's own validation rejects it — the retry loop
|
|
6
|
+
* then often repeats the SAME shape (worst observed: 8 identical failures in
|
|
7
|
+
* a row). Two shapes dominate:
|
|
8
|
+
*
|
|
9
|
+
* 1. Stringified JSON composites: `view_range: "[100, 145]"` (a JSON string
|
|
10
|
+
* instead of the native array; 24 failures), `input: "{...}"` for
|
|
11
|
+
* cordis_inspect_query (3). The deterministic fix is one JSON.parse.
|
|
12
|
+
* 2. Empty string for an enum-ish field: `read` with `encoding: ""`
|
|
13
|
+
* (26 failures) — the tool wants the field absent, not blank.
|
|
14
|
+
*
|
|
15
|
+
* This layer coerces those shapes BEFORE the tool implementations validate
|
|
16
|
+
* (better-edit's requireViewRange / normalizeEncoding, or the native
|
|
17
|
+
* defineTool INVALID_ARGS check) and before guards/approvals observe the
|
|
18
|
+
* arguments, so the call succeeds transparently instead of burning a retry.
|
|
19
|
+
*
|
|
20
|
+
* Conservative by construction:
|
|
21
|
+
* - ONLY whitelisted field names are touched (observed-broken plus same-shape
|
|
22
|
+
* kin); content-bearing string fields like `command` are never parsed.
|
|
23
|
+
* - A stringified value is replaced only when JSON.parse yields a value that
|
|
24
|
+
* satisfies the field's structural assertion. Anything else keeps the
|
|
25
|
+
* original string so the tool's own [TO MODEL] error reaches the model
|
|
26
|
+
* unchanged — sanitation must never swallow or rewrite a real error.
|
|
27
|
+
* - Any internal error fails open (next()), never denies, never throws.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Structural assertions for whitelisted fields (tool-level code validates
|
|
31
|
+
* the contents; we only gate the composite SHAPE). */
|
|
32
|
+
const isPairRange = (value) =>
|
|
33
|
+
Array.isArray(value) && value.length === 2 &&
|
|
34
|
+
value.every((item) => Number.isInteger(item) && item > 0);
|
|
35
|
+
const isPlainArray = (value) => Array.isArray(value);
|
|
36
|
+
const isPlainObject = (value) =>
|
|
37
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
38
|
+
|
|
39
|
+
/** Field-name whitelist → structural assertion. Field names are narrow enough
|
|
40
|
+
* to stand alone (view_range/edits: str_replace_editor; windows: read;
|
|
41
|
+
* input: cordis_inspect_query) — no tool-name gate, so future tools reusing
|
|
42
|
+
* the same names get the same tolerance. */
|
|
43
|
+
const SHAPE_FIELDS = {
|
|
44
|
+
view_range: isPairRange,
|
|
45
|
+
windows: isPlainArray,
|
|
46
|
+
edits: isPlainArray,
|
|
47
|
+
input: isPlainObject,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Fields where an empty string means "not provided" (tool default applies). */
|
|
51
|
+
const EMPTY_UNSET_FIELDS = ["encoding"];
|
|
52
|
+
|
|
53
|
+
/** Coerce known broken argument shapes. Returns `{ args, fixes }` when at
|
|
54
|
+
* least one coercion applied, `null` when the arguments are clean (or not a
|
|
55
|
+
* plain object). Pure: never mutates the input object. */
|
|
56
|
+
export function sanitizeToolArgs(args) {
|
|
57
|
+
if (args === null || typeof args !== "object" || Array.isArray(args))
|
|
58
|
+
return null;
|
|
59
|
+
const out = { ...args };
|
|
60
|
+
const fixes = [];
|
|
61
|
+
for (const [field, assert] of Object.entries(SHAPE_FIELDS)) {
|
|
62
|
+
const value = out[field];
|
|
63
|
+
if (typeof value !== "string") continue;
|
|
64
|
+
const trimmed = value.trim();
|
|
65
|
+
if (!trimmed.startsWith("[") && !trimmed.startsWith("{")) continue;
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(trimmed);
|
|
69
|
+
} catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!assert(parsed)) continue;
|
|
73
|
+
out[field] = parsed;
|
|
74
|
+
fixes.push(`${field}: stringified JSON -> ${Array.isArray(parsed) ? "array" : "object"}`);
|
|
75
|
+
}
|
|
76
|
+
for (const field of EMPTY_UNSET_FIELDS) {
|
|
77
|
+
if (out[field] === "") {
|
|
78
|
+
delete out[field];
|
|
79
|
+
fixes.push(`${field}: "" -> unset`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return fixes.length > 0 ? { args: out, fixes } : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Mount the sanitation layer (one `tools/pre-execute` listener). Runs before
|
|
86
|
+
* guards and before every tool implementation's own validation; replaces
|
|
87
|
+
* `exec.arguments` wholesale (lossless plain JSON) when a known shape was
|
|
88
|
+
* coerced, then always calls next(). */
|
|
89
|
+
export function applyToolArgsSanitize(ctx, config) {
|
|
90
|
+
const warn = (message) => ctx.logger.warn(`dsh-switchman: ${message}`);
|
|
91
|
+
ctx.on("tools/pre-execute", (exec, next) => {
|
|
92
|
+
try {
|
|
93
|
+
const result = sanitizeToolArgs(exec?.arguments);
|
|
94
|
+
if (result !== null) {
|
|
95
|
+
exec.arguments = result.args;
|
|
96
|
+
ctx.logger.debug(
|
|
97
|
+
`dsh-switchman: sanitized ${exec?.name ?? "?"} arguments (${result.fixes.join("; ")})`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return next();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
warn(`tool-args sanitation failed open: ${error?.message ?? error}`);
|
|
103
|
+
return next();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
package/index.js
CHANGED
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
* ctx_handover agent tool, and the /ctx-pause, /ctx-resume,
|
|
26
26
|
* /ctx-handover commands.
|
|
27
27
|
*
|
|
28
|
+
* The tool-argument sanitation layer (host/tool-args-sanitize.js) is a
|
|
29
|
+
* `tools/pre-execute` waterfall listener that transparently coerces known
|
|
30
|
+
* broken argument shapes — stringified-JSON composites ("[100, 145]" instead
|
|
31
|
+
* of [100, 145]) and empty-string enum fields (encoding: "") — before the
|
|
32
|
+
* tool implementations validate them, eliminating a recurring class of
|
|
33
|
+
* self-correcting tool-call retries observed in production session logs.
|
|
28
34
|
* Teams mode mounts two more layers: host/teams.js carries the team clauses
|
|
29
35
|
* extracted from the cordis.patch.yml suffix in a `[SWITCHMAN:TEAMS]` section
|
|
30
36
|
* (order 10250, injected only while teamsMode is ON) plus the deferred ON
|
|
@@ -45,6 +51,13 @@
|
|
|
45
51
|
* defensive ctx.get reads (NOT declared in inject): a deployment without the
|
|
46
52
|
* llm service leaves this layer a warn-once no-op — a documented runtime
|
|
47
53
|
* precondition, like sessionQuery/pluginManager above.
|
|
54
|
+
* The PTC direct-call guard (host/ptc-guard.js) is a final-say prompt section
|
|
55
|
+
* (order 10510) rendered only when the viewing scope resolves to the PTC tool
|
|
56
|
+
* executor — detected per assembly via ctx.tools.selectExecutor(context.scope)
|
|
57
|
+
* plus the executor's describe() knownNames — restating that every named tool
|
|
58
|
+
* must be called inside a run_code program (`await tools.name(args)`), so
|
|
59
|
+
* doctrines and skill catalogs that name tools directly can no longer trigger
|
|
60
|
+
* `unknown tool` rejections in PTC sessions.
|
|
48
61
|
*/
|
|
49
62
|
|
|
50
63
|
import { readdirSync, readFileSync } from "node:fs";
|
|
@@ -64,6 +77,8 @@ import { applyDispatch } from "./host/dispatch.js";
|
|
|
64
77
|
// Agent Teams layer (teams-mode doctrine section + bundle/whitelist
|
|
65
78
|
// orchestration; the clause text extracted from the cordis.patch.yml suffix).
|
|
66
79
|
import { applyTeams } from "./host/teams.js";
|
|
80
|
+
// PTC direct-call guard (final-say transport rule for PTC-executor scopes).
|
|
81
|
+
import { applyPtcGuard } from "./host/ptc-guard.js";
|
|
67
82
|
// Subagent model-visibility layer (post-execute `model:` annotation).
|
|
68
83
|
import { applySubagentModel } from "./host/subagent-model.js";
|
|
69
84
|
|
|
@@ -81,6 +96,9 @@ import { applyIntent } from "./host/intent.js";
|
|
|
81
96
|
// Client-bridge routes (config snapshot/fenced write + model catalog for the
|
|
82
97
|
// restricted browser half, which has no configForms/remote services).
|
|
83
98
|
import { applyRoutes } from "./host/routes.js";
|
|
99
|
+
// Tool-argument sanitation (transparent coercion of stringified-JSON and
|
|
100
|
+
// empty-string argument shapes before tool validation).
|
|
101
|
+
import { applyToolArgsSanitize } from "./host/tool-args-sanitize.js";
|
|
84
102
|
|
|
85
103
|
/** Cordis plugin name. */
|
|
86
104
|
const name = "dsh-switchman";
|
|
@@ -188,7 +206,8 @@ function loadSkillCandidates(root, warn) {
|
|
|
188
206
|
/** Register the bundled dsh-switchman skill provider on `ctx.skills` and
|
|
189
207
|
* mount the language (lang), dispatch (dispatch), teams (teams), watermark
|
|
190
208
|
* (context-watch), vision (vision), intent routing (intent), subagent model
|
|
191
|
-
* visibility (subagent-model),
|
|
209
|
+
* visibility (subagent-model), client-bridge (routes), and the PTC
|
|
210
|
+
* direct-call guard (ptc-guard) layers. */
|
|
192
211
|
function apply(ctx, config) {
|
|
193
212
|
const root = fileURLToPath(new URL("./skills/", import.meta.url));
|
|
194
213
|
const warn = (message) => ctx.logger.warn(`dsh-switchman: ${message}`);
|
|
@@ -205,12 +224,14 @@ function apply(ctx, config) {
|
|
|
205
224
|
applyLanguage(ctx, config);
|
|
206
225
|
applyDispatch(ctx, config);
|
|
207
226
|
applyTeams(ctx, config);
|
|
227
|
+
applyPtcGuard(ctx);
|
|
208
228
|
applyContextWatch(ctx, config);
|
|
209
229
|
applyContextTrim(ctx, config);
|
|
210
230
|
applyVision(ctx, config);
|
|
211
231
|
applyIntent(ctx, config);
|
|
212
232
|
applySubagentModel(ctx);
|
|
213
233
|
applyRoutes(ctx, config);
|
|
234
|
+
applyToolArgsSanitize(ctx, config);
|
|
214
235
|
}
|
|
215
236
|
|
|
216
237
|
export { apply, inject, name };
|