dsh-switchman 1.11.1 → 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/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/routes.js +40 -4
- package/host/teams.js +2 -1
- package/host/tool-args-sanitize.js +106 -0
- package/index.js +10 -0
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -198,6 +198,7 @@ window.__ModuleLoader__.load({
|
|
|
198
198
|
{ field: 'wmForceTokens', min: 3000, def: 150000 },
|
|
199
199
|
{ field: 'wmReadBudgetTokens', min: 200, def: 3000 },
|
|
200
200
|
{ field: 'wmSubagentForceTokens', min: 0, def: 0 },
|
|
201
|
+
{ field: 'wmSubagentWindowRatio', min: 0.1, def: 0.75 },
|
|
201
202
|
];
|
|
202
203
|
|
|
203
204
|
/** The three thresholds that must keep a strict ascending order. */
|
|
@@ -213,6 +214,10 @@ window.__ModuleLoader__.load({
|
|
|
213
214
|
label: 'wmSubagentForceLabel',
|
|
214
215
|
help: 'wmSubagentForceHelp',
|
|
215
216
|
},
|
|
217
|
+
wmSubagentWindowRatio: {
|
|
218
|
+
label: 'wmSubagentWindowRatioLabel',
|
|
219
|
+
help: 'wmSubagentWindowRatioHelp',
|
|
220
|
+
},
|
|
216
221
|
};
|
|
217
222
|
|
|
218
223
|
/** Intent-routing classifier kinds (design: intent-routing.md §2;
|
|
@@ -367,12 +372,16 @@ window.__ModuleLoader__.load({
|
|
|
367
372
|
/** Stable identity of one provider/model route. */
|
|
368
373
|
const routeKey = (route) => `${route.provider}\0${route.model}`;
|
|
369
374
|
|
|
370
|
-
/** One well-formed {provider, model} route, or null. */
|
|
375
|
+
/** One well-formed {provider, model[, effort]} route, or null. */
|
|
371
376
|
function sanitizeRoute(route) {
|
|
372
377
|
if (route === null || typeof route !== 'object') return null;
|
|
373
378
|
if (typeof route.provider !== 'string' || route.provider === '') return null;
|
|
374
379
|
if (typeof route.model !== 'string' || route.model === '') return null;
|
|
375
|
-
|
|
380
|
+
// Trim side routes may carry an optional effort pin; keep it on the
|
|
381
|
+
// round-trip so a settings save never strips it (intent routes omit it).
|
|
382
|
+
const out = { provider: route.provider, model: route.model };
|
|
383
|
+
if (typeof route.effort === 'string' && route.effort !== '') out.effort = route.effort;
|
|
384
|
+
return out;
|
|
376
385
|
}
|
|
377
386
|
|
|
378
387
|
/** One well-formed rank entry ({provider, model[, tier]}), or null;
|
|
@@ -896,6 +905,9 @@ window.__ModuleLoader__.load({
|
|
|
896
905
|
wmSubagentForceLabel: '子智能体强制阈值',
|
|
897
906
|
wmSubagentForceHelp:
|
|
898
907
|
'仅覆盖子智能体的 force 档(soft/hard 与主会话共用);0 = 跟随上方强制水位。',
|
|
908
|
+
wmSubagentWindowRatioLabel: '子智能体窗口比例',
|
|
909
|
+
wmSubagentWindowRatioHelp:
|
|
910
|
+
'按子智能体自身模型窗口缩放三档:force = min(静态档,窗口×比例),soft/hard 同比收缩;只收紧不放宽。',
|
|
899
911
|
wmSubagentFollowForce: '跟随 force 档(0)',
|
|
900
912
|
wmCommands:
|
|
901
913
|
'命令:/ctx-pause 暂停水位干预 · /ctx-resume 恢复干预 · /ctx-handover 立即写交接文档并压缩。',
|
|
@@ -912,6 +924,7 @@ window.__ModuleLoader__.load({
|
|
|
912
924
|
trimSideModelLabel: '摘要模型',
|
|
913
925
|
trimSideModelHelp: '折叠摘要使用的模型路由;默认「跟随会话模型」。阶梯蒸馏同样使用该路由。',
|
|
914
926
|
trimFollowSession: '跟随会话模型',
|
|
927
|
+
trimEffortPinLow: '默认(pin low)',
|
|
915
928
|
trimFoldFallbackModelLabel: '折叠异常兜底模型',
|
|
916
929
|
trimFoldFallbackModelHelp: '摘要模型调用失败时改用该模型重试;仍失败则压力折叠退回机械折叠。尽量选择没有冷却、风控或并发限制的模型,避免与主会话共用同一账号。留空 = 无兜底。',
|
|
917
930
|
trimFallbackUnset: '未设置(失败时直接机械折叠)',
|
|
@@ -1160,6 +1173,9 @@ window.__ModuleLoader__.load({
|
|
|
1160
1173
|
wmSubagentForceLabel: 'Subagent force threshold',
|
|
1161
1174
|
wmSubagentForceHelp:
|
|
1162
1175
|
'Overrides only the subagent force tier (soft/hard are shared with the main session); 0 follows the force watermark above.',
|
|
1176
|
+
wmSubagentWindowRatioLabel: 'Subagent window ratio',
|
|
1177
|
+
wmSubagentWindowRatioHelp:
|
|
1178
|
+
'Scale the subagent tiers to its own model window: force = min(static tier, window × ratio); soft/hard shrink by the same factor. Tightens only, never loosens.',
|
|
1163
1179
|
wmSubagentFollowForce: 'Follow the force tier (0)',
|
|
1164
1180
|
wmCommands:
|
|
1165
1181
|
'Commands: /ctx-pause suspends watermark actions · /ctx-resume resumes them · /ctx-handover writes the handover doc and compacts now.',
|
|
@@ -1179,6 +1195,7 @@ window.__ModuleLoader__.load({
|
|
|
1179
1195
|
trimSideModelHelp:
|
|
1180
1196
|
'Route for fold summaries; defaults to following the session model. Also used by ladder distillations.',
|
|
1181
1197
|
trimFollowSession: 'Follow session model',
|
|
1198
|
+
trimEffortPinLow: 'Default (pin low)',
|
|
1182
1199
|
trimFoldFallbackModelLabel: 'Fold fallback model',
|
|
1183
1200
|
trimFoldFallbackModelHelp:
|
|
1184
1201
|
'When the summary route fails, the fold retries on this model before degrading a pressure fold to a mechanical one. Prefer a model with headroom on cooldowns, risk control, and concurrency limits — avoid sharing the pinned account with the main session. Empty = no fallback leg.',
|
|
@@ -1434,6 +1451,9 @@ window.__ModuleLoader__.load({
|
|
|
1434
1451
|
wmSubagentCapHelp: '開啟後子智能體可用下方獨立強制閾值;關閉則完全跟隨上方三檔水位。',
|
|
1435
1452
|
wmSubagentForceLabel: '子智能體強制閾值',
|
|
1436
1453
|
wmSubagentForceHelp: '僅覆蓋子智能體的 force 檔(soft/hard 與主會話共用);0 = 跟隨上方強制水位。',
|
|
1454
|
+
wmSubagentWindowRatioLabel: '子代理視窗比例',
|
|
1455
|
+
wmSubagentWindowRatioHelp:
|
|
1456
|
+
'按子代理自身模型視窗縮放三檔:force = min(靜態檔,視窗×比例),soft/hard 同比收縮;只收緊不放寬。',
|
|
1437
1457
|
wmSubagentFollowForce: '跟隨 force 檔(0)',
|
|
1438
1458
|
wmCommands: '命令:/ctx-pause 暫停水位干預 · /ctx-resume 恢復干預 · /ctx-handover 立即寫交接文件並壓縮。',
|
|
1439
1459
|
trimSectionTitle: '上下文裁剪',
|
|
@@ -1449,6 +1469,7 @@ window.__ModuleLoader__.load({
|
|
|
1449
1469
|
trimSideModelLabel: '摘要模型',
|
|
1450
1470
|
trimSideModelHelp: '摺疊摘要使用的模型路由;預設「跟隨對話模型」。階梯蒸餾同樣使用該路由。',
|
|
1451
1471
|
trimFollowSession: '跟隨對話模型',
|
|
1472
|
+
trimEffortPinLow: '預設(pin low)',
|
|
1452
1473
|
trimFoldFallbackModelLabel: '摺疊異常備援模型',
|
|
1453
1474
|
trimFoldFallbackModelHelp: '摘要模型呼叫失敗時改用該模型重試;仍失敗則壓力摺疊退回機械摺疊。盡量選擇沒有冷卻、風控或並行限制的模型,避免與主對話共用同一帳號。留空 = 無備援。',
|
|
1454
1475
|
trimFallbackUnset: '未設定(失敗時直接機械摺疊)',
|
|
@@ -1677,6 +1698,9 @@ window.__ModuleLoader__.load({
|
|
|
1677
1698
|
wmSubagentCapHelp: 'オンにすると、下の専用強制しきい値を使えます。オフなら上の 3 段階の水位に完全に従います。',
|
|
1678
1699
|
wmSubagentForceLabel: 'サブエージェント強制しきい値',
|
|
1679
1700
|
wmSubagentForceHelp: 'サブエージェントの force 段階のみを上書きします(soft/hard はメインセッションと共通)。0 なら上の強制水位に従います。',
|
|
1701
|
+
wmSubagentWindowRatioLabel: 'サブエージェント窓比率',
|
|
1702
|
+
wmSubagentWindowRatioHelp:
|
|
1703
|
+
'サブエージェント自身のモデル窓に合わせて3段階を縮小:force = min(静的しきい値,窓×比率)。soft/hardも同率で縮小。引き締めのみ。',
|
|
1680
1704
|
wmSubagentFollowForce: 'force 段階に従う(0)',
|
|
1681
1705
|
wmCommands: 'コマンド:/ctx-pause で水位アクションを一時停止 · /ctx-resume で再開 · /ctx-handover で今すぐ引き継ぎ文書を書いて圧縮。',
|
|
1682
1706
|
trimSectionTitle: 'コンテキストトリム',
|
|
@@ -1771,6 +1795,7 @@ window.__ModuleLoader__.load({
|
|
|
1771
1795
|
'分類失敗時:前回ルート → フォールバック → 候補の先頭。空欄は候補の先頭。',
|
|
1772
1796
|
intentRouteUnset: '未設定',
|
|
1773
1797
|
intentEffortFollow: 'モデルのデフォルト',
|
|
1798
|
+
trimEffortPinLow: 'デフォルト(pin low)',
|
|
1774
1799
|
intentDockAuto: '自動',
|
|
1775
1800
|
intentDockUnrouted: '未ルーティング',
|
|
1776
1801
|
intentDockUsing: '使用中',
|
|
@@ -1904,8 +1929,12 @@ window.__ModuleLoader__.load({
|
|
|
1904
1929
|
wmSubagentCapHelp: '켜면 서브에이전트가 아래 전용 강제 임계값을 사용합니다. 끄면 위 세 수위를 그대로 따릅니다.',
|
|
1905
1930
|
wmSubagentForceLabel: '서브에이전트 강제 임계값',
|
|
1906
1931
|
wmSubagentForceHelp: '서브에이전트의 force 단계만 덮어씁니다(soft/hard는 메인 세션과 공유). 0이면 위 강제 수위를 따릅니다.',
|
|
1932
|
+
wmSubagentWindowRatioLabel: '서브에이전트 창 비율',
|
|
1933
|
+
wmSubagentWindowRatioHelp:
|
|
1934
|
+
'서브에이전트 자체 모델 창에 맞춰 3단계 축소: force = min(정적 기준, 창×비율). soft/hard도 같은 비율로 축소. 완화 없이 강화만.',
|
|
1907
1935
|
wmSubagentFollowForce: 'force 단계 따르기(0)',
|
|
1908
1936
|
wmCommands: '명령: /ctx-pause 수위 개입 일시 중지 · /ctx-resume 재개 · /ctx-handover 지금 핸드오버 문서를 작성하고 압축.',
|
|
1937
|
+
trimEffortPinLow: '기본 (low 고정)',
|
|
1909
1938
|
trimSectionTitle: '컨텍스트 트림',
|
|
1910
1939
|
trimModeLabel: '트림 모드',
|
|
1911
1940
|
trimModeHelp:
|
|
@@ -2130,8 +2159,12 @@ window.__ModuleLoader__.load({
|
|
|
2130
2159
|
wmSubagentCapHelp: 'Subagents dürfen die eigene erzwungene Schwelle unten nutzen; aus = vollständig den drei Stufen oben folgen.',
|
|
2131
2160
|
wmSubagentForceLabel: 'Erzwungene Schwelle für Subagents',
|
|
2132
2161
|
wmSubagentForceHelp: 'Überschreibt nur die Force-Stufe der Subagents (soft/hard teilen sich mit der Haupt-Session); 0 folgt dem erzwungenen Wasserstand oben.',
|
|
2162
|
+
wmSubagentWindowRatioLabel: 'Subagent-Fensterquote',
|
|
2163
|
+
wmSubagentWindowRatioHelp:
|
|
2164
|
+
'Skaliert die Stufen auf das eigene Modellfenster des Subagenten: force = min(statische Stufe, Fenster × Quote); soft/hard schrumpfen im selben Faktor. Verengt nur, lockert nie.',
|
|
2133
2165
|
wmSubagentFollowForce: 'Der Force-Stufe folgen (0)',
|
|
2134
2166
|
wmCommands: 'Befehle: /ctx-pause pausiert Wasserstand-Aktionen · /ctx-resume setzt sie fort · /ctx-handover schreibt das Übergabedokument jetzt und komprimiert sofort.',
|
|
2167
|
+
trimEffortPinLow: 'Standard (pin low)',
|
|
2135
2168
|
trimSectionTitle: 'Kontext-Trim',
|
|
2136
2169
|
trimModeLabel: 'Trim-Modus',
|
|
2137
2170
|
trimModeHelp:
|
|
@@ -2359,8 +2392,12 @@ window.__ModuleLoader__.load({
|
|
|
2359
2392
|
wmSubagentCapHelp: 'Permite que los subagentes usen el umbral forzado propio de abajo; desactivado = seguir por completo los tres niveles de arriba.',
|
|
2360
2393
|
wmSubagentForceLabel: 'Umbral forzado de subagentes',
|
|
2361
2394
|
wmSubagentForceHelp: 'Solo sobrescribe el nivel forzado de los subagentes (soft/hard se comparten con la sesión principal); 0 sigue el nivel forzado de arriba.',
|
|
2395
|
+
wmSubagentWindowRatioLabel: 'Ratio de ventana del subagente',
|
|
2396
|
+
wmSubagentWindowRatioHelp:
|
|
2397
|
+
'Escala los niveles a la ventana del propio modelo del subagente: force = min(nivel estático, ventana × ratio); soft/hard se reducen por el mismo factor. Solo restringe, nunca amplía.',
|
|
2362
2398
|
wmSubagentFollowForce: 'Seguir el nivel forzado (0)',
|
|
2363
2399
|
wmCommands: 'Comandos: /ctx-pause suspende las acciones del nivel · /ctx-resume las reanuda · /ctx-handover escribe el documento y compacta ahora.',
|
|
2400
|
+
trimEffortPinLow: 'Predeterminado (pin low)',
|
|
2364
2401
|
trimSectionTitle: 'Recorte de contexto',
|
|
2365
2402
|
trimModeLabel: 'Modo de recorte',
|
|
2366
2403
|
trimModeHelp:
|
|
@@ -2589,8 +2626,12 @@ window.__ModuleLoader__.load({
|
|
|
2589
2626
|
wmSubagentCapHelp: 'Laisse les subagents utiliser le seuil force dédié ci-dessous ; désactivé = suivent intégralement les trois paliers ci-dessus.',
|
|
2590
2627
|
wmSubagentForceLabel: 'Seuil force des subagents',
|
|
2591
2628
|
wmSubagentForceHelp: 'Ne remplace que le palier force des subagents (soft/hard partagés avec la session principale) ; 0 suit la ligne d’eau force ci-dessus.',
|
|
2629
|
+
wmSubagentWindowRatioLabel: 'Ratio de fenêtre du sous-agent',
|
|
2630
|
+
wmSubagentWindowRatioHelp:
|
|
2631
|
+
'Ajuste les paliers à la fenêtre propre du modèle du sous-agent : force = min(palier statique, fenêtre × ratio) ; soft/hard réduits du même facteur. Ne fait que resserrer, jamais relâcher.',
|
|
2592
2632
|
wmSubagentFollowForce: 'Suivre le palier force (0)',
|
|
2593
2633
|
wmCommands: 'Commandes : /ctx-pause suspend les actions de niveau d’eau · /ctx-resume les reprend · /ctx-handover écrit le document et compacte maintenant.',
|
|
2634
|
+
trimEffortPinLow: 'Par défaut (pin low)',
|
|
2594
2635
|
trimSectionTitle: 'Rognage du contexte',
|
|
2595
2636
|
trimModeLabel: 'Mode de rognage',
|
|
2596
2637
|
trimModeHelp:
|
|
@@ -2819,8 +2860,12 @@ window.__ModuleLoader__.load({
|
|
|
2819
2860
|
wmSubagentCapHelp: 'Lascia i subagent usare la soglia force dedicata qui sotto; spento = seguono appieno le tre soglie sopra.',
|
|
2820
2861
|
wmSubagentForceLabel: 'Soglia force dei subagent',
|
|
2821
2862
|
wmSubagentForceHelp: 'Sostituisce solo la soglia force dei subagent (soft/hard restano condivise con la sessione principale); 0 segue la soglia force sopra.',
|
|
2863
|
+
wmSubagentWindowRatioLabel: 'Rapporto finestra del subagente',
|
|
2864
|
+
wmSubagentWindowRatioHelp:
|
|
2865
|
+
'Ridimensiona i livelli alla finestra del modello del subagente: force = min(livello statico, finestra × rapporto); soft/hard si riducono dello stesso fattore. Solo restrizione, mai allentamento.',
|
|
2822
2866
|
wmSubagentFollowForce: 'Segui la soglia force (0)',
|
|
2823
2867
|
wmCommands: 'Comandi: /ctx-pause sospende le azioni da livello dell’acqua · /ctx-resume le riprende · /ctx-handover scrive il documento e compatta subito.',
|
|
2868
|
+
trimEffortPinLow: 'Predefinito (pin low)',
|
|
2824
2869
|
trimSectionTitle: 'Riduzione del contesto',
|
|
2825
2870
|
trimModeLabel: 'Modalità di riduzione',
|
|
2826
2871
|
trimModeHelp:
|
|
@@ -3049,8 +3094,12 @@ window.__ModuleLoader__.load({
|
|
|
3049
3094
|
wmSubagentCapHelp: 'Deixa os subagents usar o limiar force dedicado abaixo; desligado = seguem integralmente os três limiares acima.',
|
|
3050
3095
|
wmSubagentForceLabel: 'Limiar force dos subagents',
|
|
3051
3096
|
wmSubagentForceHelp: 'Sobrepõe apenas o limiar force dos subagents (soft/hard são compartilhados com a sessão principal); 0 segue o limiar force acima.',
|
|
3097
|
+
wmSubagentWindowRatioLabel: 'Razão de janela do subagente',
|
|
3098
|
+
wmSubagentWindowRatioHelp:
|
|
3099
|
+
'Escala os níveis para a janela do próprio modelo do subagente: force = min(nível estático, janela × razão); soft/hard encolhem pelo mesmo fator. Apenas aperta, nunca afrouxa.',
|
|
3052
3100
|
wmSubagentFollowForce: 'Seguir o limiar force (0)',
|
|
3053
3101
|
wmCommands: 'Comandos: /ctx-pause suspende as ações de nível de água · /ctx-resume as retoma · /ctx-handover escreve o documento e compacta agora.',
|
|
3102
|
+
trimEffortPinLow: 'Padrão (pin low)',
|
|
3054
3103
|
trimSectionTitle: 'Corte de contexto',
|
|
3055
3104
|
trimModeLabel: 'Modo de corte',
|
|
3056
3105
|
trimModeHelp:
|
|
@@ -3279,8 +3328,12 @@ window.__ModuleLoader__.load({
|
|
|
3279
3328
|
wmSubagentCapHelp: 'Субагенты используют отдельный порог force ниже; выключено = полностью следуют трём линиям выше.',
|
|
3280
3329
|
wmSubagentForceLabel: 'Порог force субагентов',
|
|
3281
3330
|
wmSubagentForceHelp: 'Меняет только линию force субагентов (soft/hard общие с главной сессией); 0 — следовать линии force выше.',
|
|
3331
|
+
wmSubagentWindowRatioLabel: 'Доля окна субагента',
|
|
3332
|
+
wmSubagentWindowRatioHelp:
|
|
3333
|
+
'Масштабирует уровни под собственное окно модели субагента: force = min(статический уровень, окно × доля); soft/hard сжимаются на тот же коэффициент. Только ужесточает, не смягчает.',
|
|
3282
3334
|
wmSubagentFollowForce: 'Следовать линии force (0)',
|
|
3283
3335
|
wmCommands: 'Команды: /ctx-pause приостанавливает действия по уровню воды · /ctx-resume возобновляет их · /ctx-handover записывает документ передачи и сжимает прямо сейчас.',
|
|
3336
|
+
trimEffortPinLow: 'По умолчанию (pin low)',
|
|
3284
3337
|
trimSectionTitle: 'Обрезка контекста',
|
|
3285
3338
|
trimModeLabel: 'Режим обрезки',
|
|
3286
3339
|
trimModeHelp:
|
|
@@ -5856,6 +5909,7 @@ window.__ModuleLoader__.load({
|
|
|
5856
5909
|
(meta) => meta.model === route.model,
|
|
5857
5910
|
);
|
|
5858
5911
|
edit(field, {
|
|
5912
|
+
...route,
|
|
5859
5913
|
provider: next,
|
|
5860
5914
|
model: kept ? route.model : '',
|
|
5861
5915
|
});
|
|
@@ -5880,18 +5934,31 @@ window.__ModuleLoader__.load({
|
|
|
5880
5934
|
disabled: !editable || route.provider === '',
|
|
5881
5935
|
'aria-label': `${label} · model`,
|
|
5882
5936
|
}, ...trimModelOptions(followMode, route, route.model)),
|
|
5937
|
+
route.provider !== ''
|
|
5938
|
+
? intentEffortSelect(
|
|
5939
|
+
`${trimId}-${field}-effort`,
|
|
5940
|
+
route,
|
|
5941
|
+
route.effort,
|
|
5942
|
+
(next) => edit(field, { ...route, effort: next }),
|
|
5943
|
+
`${label} · effort`,
|
|
5944
|
+
t('trimEffortPinLow'),
|
|
5945
|
+
)
|
|
5946
|
+
: null,
|
|
5883
5947
|
);
|
|
5884
5948
|
};
|
|
5885
5949
|
/** Reasoning-effort select for one intent route: the model's
|
|
5886
5950
|
* wire efforts when the catalog reports them, the EFFORTS
|
|
5887
5951
|
* fallback otherwise; '' = adapter default. A stored value
|
|
5888
|
-
* missing from the list stays selectable.
|
|
5952
|
+
* missing from the list stays selectable. emptyLabel replaces
|
|
5953
|
+
* the '' option label (trim rows pin low instead of following
|
|
5954
|
+
* the model default). */
|
|
5889
5955
|
const intentEffortSelect = (
|
|
5890
5956
|
id,
|
|
5891
5957
|
route,
|
|
5892
5958
|
value,
|
|
5893
5959
|
onCommit,
|
|
5894
5960
|
ariaLabel,
|
|
5961
|
+
emptyLabel,
|
|
5895
5962
|
) => {
|
|
5896
5963
|
const meta = catalogIndex.get(routeKey(route));
|
|
5897
5964
|
const wire =
|
|
@@ -5915,7 +5982,7 @@ window.__ModuleLoader__.load({
|
|
|
5915
5982
|
style: STYLE.effortSelect,
|
|
5916
5983
|
'aria-label': ariaLabel,
|
|
5917
5984
|
},
|
|
5918
|
-
h('option', { value: '' }, t('intentEffortFollow')),
|
|
5985
|
+
h('option', { value: '' }, emptyLabel ?? t('intentEffortFollow')),
|
|
5919
5986
|
...[...wire, ...pending].map((entry) =>
|
|
5920
5987
|
h('option', { value: entry.id }, entry.name ?? entry.id),
|
|
5921
5988
|
),
|
package/cordis.patch.yml
CHANGED
|
@@ -633,3 +633,32 @@
|
|
|
633
633
|
- insert:
|
|
634
634
|
- id: dsh-switchman-host
|
|
635
635
|
name: dsh-switchman
|
|
636
|
+
config:
|
|
637
|
+
langScope: project
|
|
638
|
+
dispatchEnforce: advice
|
|
639
|
+
wmSoftTokens: 80000
|
|
640
|
+
wmHardTokens: 95000
|
|
641
|
+
wmForceTokens: 150000
|
|
642
|
+
wmReadBudgetTokens: 3000
|
|
643
|
+
wmDenyMode: cap
|
|
644
|
+
wmAutoHandover: true
|
|
645
|
+
wmSubagentCap: true
|
|
646
|
+
wmSubagentForceTokens: 0
|
|
647
|
+
wmSubagentWindowRatio: 0.75
|
|
648
|
+
teamsMode: true
|
|
649
|
+
syncWhitelist: true
|
|
650
|
+
trimEnabled: true
|
|
651
|
+
trimMode: auto
|
|
652
|
+
trimGrowthTokens: 22500
|
|
653
|
+
trimIncrementTokens: 25000
|
|
654
|
+
trimPressurePercent: 65
|
|
655
|
+
trimEmergencyPercent: 95
|
|
656
|
+
trimWindowTokens: 0
|
|
657
|
+
trimProtectedTail: 10
|
|
658
|
+
trimMaxFoldsPerHour: 30
|
|
659
|
+
trimLadderEnabled: true
|
|
660
|
+
trimLadderT2Tokens: 8000
|
|
661
|
+
trimLadderT2MinBlocks: 3
|
|
662
|
+
trimLadderT3Tokens: 6000
|
|
663
|
+
trimLadderT3MinBlocks: 2
|
|
664
|
+
surfaceFold: true
|
package/host/config.js
CHANGED
|
@@ -67,10 +67,14 @@ export const IntentCandidateSchema = z.object({
|
|
|
67
67
|
* ModelRouteSchema this accepts the "unset" `{provider:"", model:""}`
|
|
68
68
|
* pair the settings page round-trips — ModelRouteSchema's `.min(1)`
|
|
69
69
|
* would reject that exact declared default on every settings write.
|
|
70
|
-
* Consumers validate non-empty before trusting the route.
|
|
70
|
+
* Consumers validate non-empty before trusting the route. effort follows
|
|
71
|
+
* the optional no-`.required()` style (schemastery passes undefined
|
|
72
|
+
* through): the trim summarizer reads it as a reasoning-effort pin;
|
|
73
|
+
* intent-layer routes leave it unset. */
|
|
71
74
|
const IntentRouteSchema = z.object({
|
|
72
75
|
provider: z.string(),
|
|
73
76
|
model: z.string(),
|
|
77
|
+
effort: z.string(),
|
|
74
78
|
});
|
|
75
79
|
|
|
76
80
|
/** All dsh-switchman settings. Field names are the settings mutation paths. */
|
|
@@ -153,6 +157,14 @@ export const Config = z.object({
|
|
|
153
157
|
wmSubagentCap: z.boolean().default(true).volatile(),
|
|
154
158
|
/** Subagent hard cap; 0 = share wmForceTokens. */
|
|
155
159
|
wmSubagentForceTokens: z.number().min(0).default(0).volatile(),
|
|
160
|
+
/** Scale subagent watermark tiers to the subagent's own model window:
|
|
161
|
+
* force = min(static force [or wmSubagentForceTokens], window × ratio);
|
|
162
|
+
* soft/hard shrink by the same factor to preserve tier spacing. The ratio
|
|
163
|
+
* can only TIGHTEN tiers, never loosen them; 1 = cap at the full model
|
|
164
|
+
* window (still tightens when the window is smaller than the static
|
|
165
|
+
* force tier). Resolution is async and cached per model route; until it
|
|
166
|
+
* lands, the static tiers apply (fail-open). */
|
|
167
|
+
wmSubagentWindowRatio: z.number().min(0.1).max(1).default(0.75).volatile(),
|
|
156
168
|
|
|
157
169
|
// --- Teams mode: doctrine injection + whitelist sync -------------------
|
|
158
170
|
/** Agent Teams master switch. ON = inject the `[SWITCHMAN:TEAMS]` doctrine
|
|
@@ -58,6 +58,7 @@ import { childSessionIdOf } from "../subagent-model.js";
|
|
|
58
58
|
// it and the stored tally takes over.
|
|
59
59
|
const trimViewBySession = new Map(); // sessionId -> { viewTokens, at, inject, lastNoneAt }
|
|
60
60
|
const trimWireBySession = new Map(); // sessionId -> { promptTokens, at } — last sampled provider prompt of an accepted MAIN dispatch (side calls never write; a tiny inherited-id usage sample must not lower the watermark's wire floor)
|
|
61
|
+
const capturedModelBySession = new Map(); // sessionId -> { provider, model, at } — last stream-captured model route, read by the watermark layer to scale subagent tiers to the session's own model window
|
|
61
62
|
/** Freshness TTL for the in-memory view/injection registry (watermark and
|
|
62
63
|
* banner display only). NEVER a fold gate: gating folds on "did the surface
|
|
63
64
|
* channel ship recently" deadlocks resumed sessions (2026-11-05). The fold
|
|
@@ -112,6 +113,19 @@ export function recentTrimViewTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_M
|
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/** Most recent captured model route ({provider, model}) for a session, or
|
|
117
|
+
* null when missing, stale, or malformed. The watermark layer reads this to
|
|
118
|
+
* look up the session's own model window (subagent tier scaling). */
|
|
119
|
+
export function recentTrimModelOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
|
|
120
|
+
try {
|
|
121
|
+
const entry = capturedModelBySession.get(sessionId);
|
|
122
|
+
if (entry === undefined || Date.now() - entry.at > maxAgeMs) return null;
|
|
123
|
+
return typeof entry.provider === "string" && typeof entry.model === "string" ? { provider: entry.provider, model: entry.model } : null;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
115
129
|
/** Meter-duality bridge, wire domain (design §8): the same number the trim
|
|
116
130
|
* pressure tiers evaluate in maybeFold — Math.max(outbound view estimate,
|
|
117
131
|
* the last sampled provider prompt) — or null when the gate rejects or
|
|
@@ -234,7 +248,11 @@ function readRoute(config, key) {
|
|
|
234
248
|
let got = value;
|
|
235
249
|
if (value !== null && typeof value === "object" && typeof value.get === "function") got = value.get();
|
|
236
250
|
if (got !== null && typeof got === "object" && typeof got.provider === "string" && got.provider !== "" && typeof got.model === "string" && got.model !== "") {
|
|
237
|
-
|
|
251
|
+
// Pass the configured effort through verbatim (IntentRouteSchema
|
|
252
|
+
// already carries the optional field); absent/empty just omits it.
|
|
253
|
+
return typeof got.effort === "string" && got.effort !== ""
|
|
254
|
+
? { provider: got.provider, model: got.model, effort: got.effort }
|
|
255
|
+
: { provider: got.provider, model: got.model };
|
|
238
256
|
}
|
|
239
257
|
} catch {
|
|
240
258
|
/* fall through */
|
|
@@ -576,6 +594,10 @@ export function applyContextTrim(ctx, config) {
|
|
|
576
594
|
* copy — message objects are treated as immutable records). */
|
|
577
595
|
function captureOf(sessionId, messages, model, viewTokens) {
|
|
578
596
|
captures.set(sessionId, { messages: messages.slice(), model, viewTokens, at: Date.now() });
|
|
597
|
+
if (model !== null && typeof model === "object" && typeof model.provider === "string" && typeof model.model === "string") {
|
|
598
|
+
capturedModelBySession.set(sessionId, { provider: model.provider, model: model.model, at: Date.now() });
|
|
599
|
+
if (capturedModelBySession.size > 200) capturedModelBySession.delete(capturedModelBySession.keys().next().value);
|
|
600
|
+
}
|
|
579
601
|
// keep the capture map bounded (200 sessions)
|
|
580
602
|
if (captures.size > 200) {
|
|
581
603
|
const oldest = captures.keys().next().value;
|
|
@@ -673,7 +695,15 @@ export function applyContextTrim(ctx, config) {
|
|
|
673
695
|
} else if (view.applied > 0) {
|
|
674
696
|
epochResetDoneBySession.delete(sessionId);
|
|
675
697
|
}
|
|
676
|
-
|
|
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.
|
|
702
|
+
const model = {
|
|
703
|
+
provider: options.provider ?? null,
|
|
704
|
+
model: options.model ?? null,
|
|
705
|
+
effort: typeof options.reasoningEffort === "string" && options.reasoningEffort !== "" ? options.reasoningEffort : null,
|
|
706
|
+
};
|
|
677
707
|
captureOf(sessionId, rawSnapshot, model, viewTokens);
|
|
678
708
|
metrics.noteView(sessionId, viewTokens, model);
|
|
679
709
|
const priorEntry = trimViewBySession.get(sessionId);
|
|
@@ -694,7 +724,10 @@ export function applyContextTrim(ctx, config) {
|
|
|
694
724
|
if (explicit !== null) return explicit;
|
|
695
725
|
const model = metrics.of(sessionId)?.lastModel ?? null;
|
|
696
726
|
if (model !== null && typeof model.provider === "string" && model.provider !== "" && typeof model.model === "string" && model.model !== "") {
|
|
697
|
-
|
|
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 };
|
|
698
731
|
}
|
|
699
732
|
return null;
|
|
700
733
|
}
|
|
@@ -733,6 +766,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
733
766
|
const summarize = (r, tag) => summarizeWithModel(llm, r, renderLadderMaterial(consumed), {
|
|
734
767
|
systemPrompt: LADDER_SYSTEM_PROMPT,
|
|
735
768
|
onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} blocks=${consumed.length} ~${approxTokens}t ladder=${decision.tier}`),
|
|
769
|
+
onNotice: (why) => decisionLog(`session=${sessionId} summary-retry route=${tag} why=${why} blocks=${consumed.length} ~${approxTokens}t ladder=${decision.tier}`),
|
|
736
770
|
});
|
|
737
771
|
let summary = route !== null ? await summarize(route, "side") : null;
|
|
738
772
|
if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
|
|
@@ -779,6 +813,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
779
813
|
const material = renderMaterial(capture.messages, startIdx, endIdx, refOf);
|
|
780
814
|
const summarize = (r, tag) => summarizeWithModel(llm, r, material, {
|
|
781
815
|
onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
|
|
816
|
+
onNotice: (why) => decisionLog(`session=${sessionId} summary-retry route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
|
|
782
817
|
});
|
|
783
818
|
summary = route !== null ? await summarize(route, "side") : null;
|
|
784
819
|
if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
|
|
@@ -67,8 +67,7 @@ export function createMetrics() {
|
|
|
67
67
|
cacheWriteTokens: null, // cache-write bucket of the last sample
|
|
68
68
|
outputTokens: null,
|
|
69
69
|
viewTokens: null, // view estimate noted by the listener pre-request
|
|
70
|
-
lastModel: null, // { provider, model } of the last stream
|
|
71
|
-
lastStreamAt: 0,
|
|
70
|
+
lastModel: null, // { provider, model, effort } of the last stream
|
|
72
71
|
totalStreams: 0,
|
|
73
72
|
};
|
|
74
73
|
}
|
|
@@ -87,8 +86,7 @@ export function createMetrics() {
|
|
|
87
86
|
noteView(sessionId, viewTokens, model) {
|
|
88
87
|
const record = recordOf(sessionId);
|
|
89
88
|
if (Number.isFinite(viewTokens)) record.viewTokens = Math.round(viewTokens);
|
|
90
|
-
if (model !== null && typeof model === "object") record.lastModel = { provider: model.provider ?? null, model: model.model ?? null };
|
|
91
|
-
record.lastStreamAt = Date.now();
|
|
89
|
+
if (model !== null && typeof model === "object") record.lastModel = { provider: model.provider ?? null, model: model.model ?? null, effort: typeof model.effort === "string" && model.effort !== "" ? model.effort : null };
|
|
92
90
|
record.totalStreams += 1;
|
|
93
91
|
return record;
|
|
94
92
|
},
|
|
@@ -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
|
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"]);
|
|
@@ -241,6 +241,69 @@ function renderSubWatermark(level) {
|
|
|
241
241
|
);
|
|
242
242
|
}
|
|
243
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
|
+
|
|
244
307
|
/** Whole live line for one assembly (the tail context); "" whenever anything
|
|
245
308
|
* needed is missing or the session sits at the silent ok tier. */
|
|
246
309
|
function renderWatermarkContext(ctx, config, state, agent) {
|
|
@@ -250,16 +313,25 @@ function renderWatermarkContext(ctx, config, state, agent) {
|
|
|
250
313
|
const th = readThresholds(config);
|
|
251
314
|
const used = measureNow(ctx, agent);
|
|
252
315
|
if (used === null) return "";
|
|
253
|
-
const level = levelOf(used, th);
|
|
254
|
-
if (level === "ok") return "";
|
|
255
316
|
if (!isRoot(ctx, agent)) {
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
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).
|
|
259
325
|
const subForce = readBool(config.wmSubagentCap, true) ? readNumber(config.wmSubagentForceTokens, 0) : 0;
|
|
260
|
-
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
|
+
);
|
|
261
331
|
return renderSubWatermark(levelOf(used, subTh));
|
|
262
332
|
}
|
|
333
|
+
const level = levelOf(used, th);
|
|
334
|
+
if (level === "ok") return "";
|
|
263
335
|
if (state.paused.has(sessionId)) return PAUSED_LINE;
|
|
264
336
|
const live = handoverOf(sessionId);
|
|
265
337
|
const elapsed = Date.now() - (state.lastHandover.get(sessionId) ?? 0);
|
|
@@ -333,6 +405,7 @@ function applyContextWatch(ctx, config) {
|
|
|
333
405
|
handoverTool: null,
|
|
334
406
|
lastHandover: new Map(),
|
|
335
407
|
readUsed: new Map(),
|
|
408
|
+
subWindow: new Map(), // "provider/model" -> { value: number|null, at } — resolved subagent model windows (see subagentWindowTokens)
|
|
336
409
|
};
|
|
337
410
|
const warn = (message) => ctx.logger?.warn?.(`dsh-switchman: ${message}`);
|
|
338
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,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
|
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,7 +82,8 @@ 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,不要据此重派或判定死亡。",
|
|
88
89
|
].join("\n");
|
|
@@ -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
|
|
@@ -81,6 +87,9 @@ import { applyIntent } from "./host/intent.js";
|
|
|
81
87
|
// Client-bridge routes (config snapshot/fenced write + model catalog for the
|
|
82
88
|
// restricted browser half, which has no configForms/remote services).
|
|
83
89
|
import { applyRoutes } from "./host/routes.js";
|
|
90
|
+
// Tool-argument sanitation (transparent coercion of stringified-JSON and
|
|
91
|
+
// empty-string argument shapes before tool validation).
|
|
92
|
+
import { applyToolArgsSanitize } from "./host/tool-args-sanitize.js";
|
|
84
93
|
|
|
85
94
|
/** Cordis plugin name. */
|
|
86
95
|
const name = "dsh-switchman";
|
|
@@ -211,6 +220,7 @@ function apply(ctx, config) {
|
|
|
211
220
|
applyIntent(ctx, config);
|
|
212
221
|
applySubagentModel(ctx);
|
|
213
222
|
applyRoutes(ctx, config);
|
|
223
|
+
applyToolArgsSanitize(ctx, config);
|
|
214
224
|
}
|
|
215
225
|
|
|
216
226
|
export { apply, inject, name };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-switchman",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.2",
|
|
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": [
|