dsh-switchman 1.10.3 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +11 -10
- package/README.es.md +11 -10
- package/README.fr.md +11 -10
- package/README.it.md +11 -10
- package/README.ja.md +11 -10
- package/README.ko.md +11 -10
- package/README.md +19 -10
- package/README.pt.md +11 -10
- package/README.ru.md +11 -10
- package/README.zh-TW.md +11 -10
- package/README.zh.md +11 -10
- package/client.js +326 -43
- package/cordis.patch.yml +24 -21
- package/host/config.js +57 -10
- package/host/context-trim/fork.js +5 -1
- package/host/context-trim/index.js +340 -183
- package/host/context-trim/metrics.js +22 -14
- package/host/context-trim/state.js +351 -50
- package/host/context-trim/summarizer.js +147 -33
- package/host/context-trim/surface.js +222 -2
- package/host/context-trim/trigger.js +170 -51
- package/host/context-trim/view.js +132 -16
- package/host/context-watch.js +81 -73
- package/host/dispatch.js +30 -5
- package/host/routes.js +64 -4
- package/host/teams.js +2 -1
- package/package.json +2 -2
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 {
|
|
56
|
+
import { 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"]);
|
|
@@ -115,11 +115,6 @@ function levelOf(used, th) {
|
|
|
115
115
|
return "ok";
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
/** `42k`-style rounding for the anchor line. */
|
|
119
|
-
function kilo(tokens) {
|
|
120
|
-
return `${Math.round(tokens / 1000)}k`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
118
|
/** Session id off an exec/invocation agent (shared agent/session id). */
|
|
124
119
|
function sessionIdOf(agent) {
|
|
125
120
|
const id = agent?.session?.id ?? agent?.id;
|
|
@@ -141,7 +136,7 @@ function isRoot(ctx, agent) {
|
|
|
141
136
|
function readThresholds(config) {
|
|
142
137
|
return {
|
|
143
138
|
soft: readNumber(config.wmSoftTokens, 80000),
|
|
144
|
-
hard: readNumber(config.wmHardTokens,
|
|
139
|
+
hard: readNumber(config.wmHardTokens, 95000),
|
|
145
140
|
force: readNumber(config.wmForceTokens, 150000),
|
|
146
141
|
};
|
|
147
142
|
}
|
|
@@ -151,15 +146,22 @@ export function measureNow(ctx, agent) {
|
|
|
151
146
|
try {
|
|
152
147
|
const measurement = agent?.session ? ctx.tokenMeter?.measure?.(agent.session) : undefined;
|
|
153
148
|
const used = Number.isFinite(measurement?.totalTokens) ? measurement.totalTokens : null;
|
|
154
|
-
// Meter duality (design §8): prefer the trim layer's
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
149
|
+
// Meter duality (design §8): prefer the trim layer's wire-domain meter —
|
|
150
|
+
// max(fresh outbound view, last sampled MAIN-dispatch prompt), the same
|
|
151
|
+
// number the trim pressure tiers evaluate — when present. Each pure
|
|
152
|
+
// source has one bias: the stored tally is wire-domain but never
|
|
153
|
+
// re-prices splice-level folds (it keeps counting material the model no
|
|
154
|
+
// longer receives — high bias), while the bare view never counts the
|
|
155
|
+
// system/tool sections (low bias). max(view, wire) fixes both and puts
|
|
156
|
+
// the watermark tiers (soft/hard/force + read budget) on the SAME
|
|
157
|
+
// boundary as the fold engine — wmHardTokens 95k ≈ trim pressure
|
|
158
|
+
// 97.5k (65% of the 150k window) — holds in units, not just in
|
|
159
|
+
// numbers. Strictly additive +
|
|
160
|
+
// fail-open: any trim failure falls back to the stored measure.
|
|
159
161
|
try {
|
|
160
162
|
const sessionId = agent?.session?.id ?? (typeof agent?.id === "string" ? agent.id : null);
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
+
const promptTokens = sessionId === null ? null : recentTrimPromptTokensOf(sessionId);
|
|
164
|
+
if (promptTokens !== null) return promptTokens;
|
|
163
165
|
} catch {
|
|
164
166
|
// fall through to the stored measure
|
|
165
167
|
}
|
|
@@ -171,58 +173,53 @@ export function measureNow(ctx, agent) {
|
|
|
171
173
|
|
|
172
174
|
// ---- watermark rendering (pure given state + measurement) ------------------
|
|
173
175
|
|
|
174
|
-
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
176
|
+
/** MODEL-VISIBILITY POLICY (docs/ladder-fold.md, bili lesson #2): the
|
|
177
|
+
* watermark NEVER shows the model a water level — no used-token counts, no
|
|
178
|
+
* budget values, no percentages, no cooldown minutes. A model told "you're
|
|
179
|
+
* at 20%" anchors on "looks healthy" and refuses to fold or wrap; with the
|
|
180
|
+
* numbers hidden, judgment roots in the task. Only BEHAVIORAL directives
|
|
181
|
+
* travel, and only when enforcement changes (a tier crossing or a handover
|
|
182
|
+
* phase change), which also keeps the runtime-context snapshot byte-stable
|
|
183
|
+
* between those events. The full numeric picture stays available to the
|
|
184
|
+
* human through the client badge. */
|
|
183
185
|
|
|
184
186
|
/** Byte-stable protocol text for root sessions: no live numbers, so the
|
|
185
187
|
* request head stays byte-identical for the whole session (KV-cache
|
|
186
|
-
* invariant)
|
|
188
|
+
* invariant). */
|
|
187
189
|
const PROTOCOL_ROOT_TEXT = [
|
|
188
|
-
"[SWITCHMAN:WATERMARK] protocol: this session is
|
|
189
|
-
"
|
|
190
|
-
"The live tier and the bucketed used-token reading (against the soft/hard/force budget) arrive ONLY as [SWITCHMAN:WATERMARK] lines inside the appended runtime-context snapshot at the conversation tail; a tier change is announced on the step it happens. /ctx-pause pauses every watermark enforcement (measurement continues), /ctx-resume restores it, /ctx-handover starts a handover immediately.",
|
|
190
|
+
"[SWITCHMAN:WATERMARK] protocol: this session's context is managed automatically (folding runs before you notice; a handover may run when needed). You will never receive token counts, percentages, or progress numbers — none exist in this protocol, and no pacing decision is yours to make from them.",
|
|
191
|
+
"Only BEHAVIORAL directives arrive, as [SWITCHMAN:WATERMARK] lines inside the appended runtime-context snapshot, when enforcement changes: a soft line asks you to prefer delegation over reading more context yourself; a hard line says broad reading is budget-limited this turn and it is time to start wrapping up; a force line is a MANDATORY wrap-up — produce the final answer from what you already hold. /ctx-pause pauses every watermark enforcement (measurement continues), /ctx-resume restores it, /ctx-handover starts a handover immediately.",
|
|
191
192
|
].join("\n");
|
|
192
193
|
|
|
193
194
|
/** Byte-stable protocol text for subagent sessions (same invariant). */
|
|
194
195
|
const PROTOCOL_SUB_TEXT = [
|
|
195
|
-
"[SWITCHMAN:WATERMARK] protocol: this subagent session is
|
|
196
|
-
"
|
|
197
|
-
"The live tier and the bucketed used-token reading arrive ONLY as [SWITCHMAN:WATERMARK] lines inside the appended runtime-context snapshot; a tier change is announced on the step it happens.",
|
|
196
|
+
"[SWITCHMAN:WATERMARK] protocol: this subagent session's context is managed automatically. You will never receive token counts, percentages, or progress numbers — judgment stays rooted in the task.",
|
|
197
|
+
"Only BEHAVIORAL directives arrive, as [SWITCHMAN:WATERMARK] lines inside the appended runtime-context snapshot, when enforcement changes: a soft line asks for narrow reads; a hard line says stop broad reading and wrap toward the final summary; a force line says stop reading and return the final summary now, ending the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps.",
|
|
198
198
|
].join("\n");
|
|
199
199
|
|
|
200
|
-
/** Static paused line (
|
|
200
|
+
/** Static paused line (never changes while paused: enforcement is off, and a
|
|
201
201
|
* frozen string emits once and never churns the snapshot again). */
|
|
202
202
|
const PAUSED_LINE =
|
|
203
203
|
"[SWITCHMAN:WATERMARK] paused — measurement continues, but every watermark enforcement stays off until /ctx-resume.";
|
|
204
204
|
|
|
205
|
-
/** Root-session live line:
|
|
206
|
-
* the
|
|
207
|
-
function renderRootWatermark(level,
|
|
205
|
+
/** Root-session live line: directive text only — no numbers (see the
|
|
206
|
+
* policy note above). "" while the session sits at the silent ok tier. */
|
|
207
|
+
function renderRootWatermark(level, status) {
|
|
208
208
|
if (level === "ok") return "";
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (level === "soft") return `${head}\nsoft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.`;
|
|
213
|
-
if (level === "hard") return `${head}\nhard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.`;
|
|
214
|
-
const lines = [head, "force tier: MANDATORY wrap-up now — produce the final answer from what you already hold."];
|
|
209
|
+
if (level === "soft") return "[SWITCHMAN:WATERMARK] soft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.";
|
|
210
|
+
if (level === "hard") return "[SWITCHMAN:WATERMARK] hard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.";
|
|
211
|
+
const lines = ["[SWITCHMAN:WATERMARK] force tier: MANDATORY wrap-up now — produce the final answer from what you already hold."];
|
|
215
212
|
const phase = status?.phase;
|
|
216
213
|
if (phase === "backup") {
|
|
217
214
|
lines.push(`context handover running: writing the handover document…`);
|
|
218
215
|
} else if (phase === "compacting") {
|
|
219
|
-
lines.push(`context handover running: compaction
|
|
216
|
+
lines.push(`context handover running: compaction in progress (retries while the turn is still running)…`);
|
|
220
217
|
} else if (phase === "continuation") {
|
|
221
218
|
lines.push(`context handover completing: waking this session to fill in the document and continue the task…`);
|
|
222
219
|
} else if (status?.autoHandover) {
|
|
223
220
|
lines.push(
|
|
224
|
-
status?.
|
|
225
|
-
? `automatic handover
|
|
221
|
+
status?.cooldown
|
|
222
|
+
? `automatic handover is cooling down; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
|
|
226
223
|
: `an automatic context handover (document + compaction + continuation) is armed and will fire at the next step boundary${status?.tool ? "; calling the ctx_handover tool triggers it immediately" : ""}.`,
|
|
227
224
|
);
|
|
228
225
|
} else {
|
|
@@ -231,21 +228,16 @@ function renderRootWatermark(level, used, th, status) {
|
|
|
231
228
|
return lines.join("\n");
|
|
232
229
|
}
|
|
233
230
|
|
|
234
|
-
/** Subagent-session live line: the same
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
|
|
238
|
-
function renderSubWatermark(used, th) {
|
|
239
|
-
const level = levelOf(used, th);
|
|
231
|
+
/** Subagent-session live line: the same tiers as the root line, directive
|
|
232
|
+
* text only — no numbers (see the policy note above). Pausing never
|
|
233
|
+
* applies to subagents (oc index.ts:187-190). */
|
|
234
|
+
function renderSubWatermark(level) {
|
|
240
235
|
if (level === "ok") return "";
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
` of budget ${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
|
|
244
|
-
if (level === "soft") return `${head}\nsoft tier: keep reads narrow and drop bulky raw output.`;
|
|
245
|
-
if (level === "hard") return `${head}\nhard tier: stop broad reading; start wrapping toward the final summary.`;
|
|
236
|
+
if (level === "soft") return "[SWITCHMAN:WATERMARK] soft tier: keep reads narrow and drop bulky raw output.";
|
|
237
|
+
if (level === "hard") return "[SWITCHMAN:WATERMARK] hard tier: stop broad reading; start wrapping toward the final summary.";
|
|
246
238
|
return (
|
|
247
|
-
|
|
248
|
-
|
|
239
|
+
"[SWITCHMAN:WATERMARK] force tier: stop reading and return the final summary now.\n" +
|
|
240
|
+
"End the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps."
|
|
249
241
|
);
|
|
250
242
|
}
|
|
251
243
|
|
|
@@ -258,24 +250,24 @@ function renderWatermarkContext(ctx, config, state, agent) {
|
|
|
258
250
|
const th = readThresholds(config);
|
|
259
251
|
const used = measureNow(ctx, agent);
|
|
260
252
|
if (used === null) return "";
|
|
253
|
+
const level = levelOf(used, th);
|
|
254
|
+
if (level === "ok") return "";
|
|
261
255
|
if (!isRoot(ctx, agent)) {
|
|
262
256
|
// Subagents share the root soft/hard thresholds; the optional
|
|
263
257
|
// wmSubagentForceTokens override replaces only the force tier
|
|
264
258
|
// (enabled by the wmSubagentCap toggle, 0/0 = follow the root set).
|
|
265
259
|
const subForce = readBool(config.wmSubagentCap, true) ? readNumber(config.wmSubagentForceTokens, 0) : 0;
|
|
266
260
|
const subTh = subForce > 0 ? { ...th, force: subForce } : th;
|
|
267
|
-
return renderSubWatermark(used, subTh);
|
|
261
|
+
return renderSubWatermark(levelOf(used, subTh));
|
|
268
262
|
}
|
|
269
263
|
if (state.paused.has(sessionId)) return PAUSED_LINE;
|
|
270
264
|
const live = handoverOf(sessionId);
|
|
271
265
|
const elapsed = Date.now() - (state.lastHandover.get(sessionId) ?? 0);
|
|
272
|
-
|
|
273
|
-
// byte-stable between steps (snapshot re-emits only on change).
|
|
274
|
-
return renderRootWatermark(levelOf(used, th), used, th, {
|
|
266
|
+
return renderRootWatermark(level, {
|
|
275
267
|
autoHandover: readBool(config.wmAutoHandover, true),
|
|
276
268
|
phase: live?.phase,
|
|
277
269
|
attempt: live?.attempt,
|
|
278
|
-
|
|
270
|
+
cooldown: elapsed < HANDOVER_COOLDOWN_MS,
|
|
279
271
|
tool: state.handoverTool ?? undefined,
|
|
280
272
|
});
|
|
281
273
|
} catch {
|
|
@@ -846,16 +838,26 @@ function applyContextWatch(ctx, config) {
|
|
|
846
838
|
readString(config.wmDenyMode) !== "deny" &&
|
|
847
839
|
Array.isArray(result?.content)
|
|
848
840
|
) {
|
|
849
|
-
return
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
841
|
+
// [2026-11-07]-[the warning branch used to return without
|
|
842
|
+
// calling next()]-[that short-circuited every post-execute
|
|
843
|
+
// handler downstream of the watermark (trim fork-link
|
|
844
|
+
// accounting, subagent model annotation) on exactly the
|
|
845
|
+
// turns that read the most. Run the pipeline first, then
|
|
846
|
+
// append the warning to whatever the pipeline accepted.]
|
|
847
|
+
return (async () => {
|
|
848
|
+
const downstream = await next();
|
|
849
|
+
const base = downstream !== null && typeof downstream === "object" && downstream.kind === "accept" && Array.isArray(downstream.content) ? downstream : result;
|
|
850
|
+
return {
|
|
851
|
+
kind: "accept",
|
|
852
|
+
content: [
|
|
853
|
+
...base.content,
|
|
854
|
+
{
|
|
855
|
+
type: "text",
|
|
856
|
+
text: `[SWITCHMAN:WATERMARK] read budget exceeded this turn (used ${Math.round(entry.used)}/${Math.round(ceiling)} tokens) — further reads may be denied; wrap up or delegate the remaining reading.`,
|
|
857
|
+
},
|
|
858
|
+
],
|
|
859
|
+
};
|
|
860
|
+
})();
|
|
859
861
|
}
|
|
860
862
|
}
|
|
861
863
|
}
|
|
@@ -929,8 +931,15 @@ function applyContextWatch(ctx, config) {
|
|
|
929
931
|
ctx.commands.register({
|
|
930
932
|
name: "ctx-handover",
|
|
931
933
|
description: "Trigger a full context handover for this session now (document + compaction + continuation).",
|
|
934
|
+
// Signal deliberately dropped (inspection W20): a turn-scoped
|
|
935
|
+
// AbortSignal shared by every /compact retry turns a user cancel into
|
|
936
|
+
// six straight failures walking the full ~530 s backoff ladder with a
|
|
937
|
+
// misleading error text — the same reason the ctx_handover TOOL path
|
|
938
|
+
// drops its turn signal. sessionCompact fabricates a fresh un-aborted
|
|
939
|
+
// signal when none arrives; user cancel lets compaction finish, like
|
|
940
|
+
// the tool path.
|
|
932
941
|
handler: (invocation) =>
|
|
933
|
-
runHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId,
|
|
942
|
+
runHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, undefined, "manual"),
|
|
934
943
|
});
|
|
935
944
|
|
|
936
945
|
// --- E. ctx_handover: the agent-callable front door to the same
|
|
@@ -989,7 +998,6 @@ function applyContextWatch(ctx, config) {
|
|
|
989
998
|
|
|
990
999
|
export {
|
|
991
1000
|
applyContextWatch,
|
|
992
|
-
bucketKilo,
|
|
993
1001
|
PAUSED_LINE,
|
|
994
1002
|
PROTOCOL_ROOT_TEXT,
|
|
995
1003
|
PROTOCOL_SUB_TEXT,
|
package/host/dispatch.js
CHANGED
|
@@ -284,15 +284,40 @@ function annotationFixOf(args, config) {
|
|
|
284
284
|
);
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
/** The assembling session's own route, from the DSH agent-loop's assemble
|
|
288
|
+
* context (`agent.ts` passes `modelSelection` with every turn assembly;
|
|
289
|
+
* older builds and non-agent assemblies omit it). Null when absent or
|
|
290
|
+
* malformed — the session-route line then does not render (fail-open,
|
|
291
|
+
* like every capability read in this file family). */
|
|
292
|
+
function sessionRouteOf(context) {
|
|
293
|
+
const route = context === null || typeof context !== "object" ? undefined : context.modelSelection;
|
|
294
|
+
if (route === null || typeof route !== "object") return null;
|
|
295
|
+
const provider = typeof route.provider === "string" ? route.provider.trim() : "";
|
|
296
|
+
const model = typeof route.model === "string" ? route.model.trim() : "";
|
|
297
|
+
if (provider === "" || model === "") return null;
|
|
298
|
+
const effort = typeof route.reasoningEffort === "string" && route.reasoningEffort !== "" ? route.reasoningEffort : null;
|
|
299
|
+
|
|
300
|
+
return { provider, model, effort };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
287
304
|
/** The `[SWITCHMAN:POOLS]` block: anchor line, one line per configured lane,
|
|
288
305
|
* then the guidance lines (byte-stable for a given configuration). Routes
|
|
289
306
|
* outside the session's DSH-authorized child models carry a ⚠not-authorized
|
|
290
307
|
* marker (after any @effort suffix) — teams mode only: with teamsMode OFF
|
|
291
308
|
* the whitelist is never read, `state.authorized` stays null, and neither
|
|
292
|
-
* the markers nor the drift guidance lines render.
|
|
293
|
-
|
|
309
|
+
* the markers nor the drift guidance lines render. When the assembling
|
|
310
|
+
* context carries the session's own model selection, a `session route:`
|
|
311
|
+
* line follows the anchor. */
|
|
312
|
+
function renderPoolsBlock(state, sessionRoute = null) {
|
|
294
313
|
const configured = state.lanePools.filter((pool) => pool.routes.length > 0);
|
|
295
314
|
const lines = [`[SWITCHMAN:POOLS] configured=${configured.length}/6 enforce=${state.enforce}`];
|
|
315
|
+
if (sessionRoute !== null) {
|
|
316
|
+
lines.push(
|
|
317
|
+
`session route: ${sessionRoute.provider}/${sessionRoute.model}${sessionRoute.effort === null ? "" : `@${sessionRoute.effort}`}` +
|
|
318
|
+
` — implicit dispatch (omitting provider/model/effort) gives the child THIS route; when it differs from the lane entries, only an explicit dispatch lands a lane model.`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
296
321
|
let anyUnauthorized = false;
|
|
297
322
|
for (const pool of state.lanePools) {
|
|
298
323
|
if (pool.routes.length === 0) continue;
|
|
@@ -317,7 +342,7 @@ function renderPoolsBlock(state) {
|
|
|
317
342
|
`Entries render as provider/model(TIER)@effort: (TIER) is the S/A/B/C capability grade and @effort a manually pinned reasoning effort — both are display-only annotations, NEVER parts of the model id.`,
|
|
318
343
|
`Decompose before dispatching: provider/model(TIER)@effort → provider=provider, model=model, reasoning_effort=effort (a pin overrides the lane default); a route without @effort carries no per-route pin — fall back to the lane's default effort above.`,
|
|
319
344
|
`Review delegations: anchor independence on the code AUTHOR's model, not the main session's (author = the agent that produced the diff: its explicit dispatch route, or the inherited session model when unspecified). Prefer a reviewer model different from the author's; also avoiding the session model is a bonus (its integration pass already covers that angle). If no pool entry differs from the author, same-model review is allowed — declare DOWNGRADED and state whether it is relative to the author or the session.`,
|
|
320
|
-
`Explicit
|
|
345
|
+
`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.`,
|
|
321
346
|
);
|
|
322
347
|
if (anyUnauthorized) {
|
|
323
348
|
lines.push(
|
|
@@ -353,11 +378,11 @@ function applyDispatch(ctx, config) {
|
|
|
353
378
|
name: "switchman:dispatch",
|
|
354
379
|
order: 10400,
|
|
355
380
|
interpolate: false,
|
|
356
|
-
text: () => {
|
|
381
|
+
text: (context) => {
|
|
357
382
|
const state = readDispatchState(ctx, config);
|
|
358
383
|
const anyConfigured = state.lanePools.some((pool) => pool.routes.length > 0) || state.modelRank.length > 0;
|
|
359
384
|
if (!anyConfigured) return "";
|
|
360
|
-
return renderPoolsBlock(state);
|
|
385
|
+
return renderPoolsBlock(state, sessionRouteOf(context));
|
|
361
386
|
},
|
|
362
387
|
});
|
|
363
388
|
|
package/host/routes.js
CHANGED
|
@@ -95,6 +95,24 @@ const NUMBER_FIELDS = [
|
|
|
95
95
|
"wmReadBudgetTokens",
|
|
96
96
|
"wmSubagentForceTokens",
|
|
97
97
|
];
|
|
98
|
+
/** Trim numbers carry their own schema bounds (host/config.js), so the
|
|
99
|
+
* shared NUMBER_FIELDS coercion (which only rejects negatives) is too
|
|
100
|
+
* loose — these validate against each field's declared min/max. This group
|
|
101
|
+
* MUST mirror the client page's TRIM_NUMBER_FIELDS: a field coerced here
|
|
102
|
+
* but never sent by the client fails every fenced write (and vice versa). */
|
|
103
|
+
const TRIM_NUMBER_FIELDS = [
|
|
104
|
+
{ field: "trimGrowthTokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 22500 },
|
|
105
|
+
{ field: "trimIncrementTokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 25000 },
|
|
106
|
+
{ field: "trimPressurePercent", min: 5, max: 100, def: 65 },
|
|
107
|
+
{ field: "trimEmergencyPercent", min: 5, max: 100, def: 95 },
|
|
108
|
+
{ field: "trimWindowTokens", min: 0, max: Number.POSITIVE_INFINITY, def: 0 },
|
|
109
|
+
{ field: "trimProtectedTail", min: 0, max: 100, def: 10 },
|
|
110
|
+
{ field: "trimLadderT2Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 8000 },
|
|
111
|
+
{ field: "trimLadderT2MinBlocks", min: 2, max: 200, def: 3 },
|
|
112
|
+
{ field: "trimLadderT3Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 6000 },
|
|
113
|
+
{ field: "trimLadderT3MinBlocks", min: 2, max: 200, def: 2 },
|
|
114
|
+
{ field: "trimMaxFoldsPerHour", min: 1, max: 60, def: 30 },
|
|
115
|
+
];
|
|
98
116
|
const BOOLEAN_FIELDS = [
|
|
99
117
|
"wmAutoHandover",
|
|
100
118
|
"wmSubagentCap",
|
|
@@ -110,6 +128,10 @@ const BOOLEAN_FIELDS = [
|
|
|
110
128
|
"syncWhitelist",
|
|
111
129
|
// Intent recognition (mirrors the intent* group in host/config.js).
|
|
112
130
|
"intentEnabled",
|
|
131
|
+
// Context trim switches (mirrors the trim group in host/config.js).
|
|
132
|
+
"trimEnabled",
|
|
133
|
+
"trimLadderEnabled",
|
|
134
|
+
"surfaceFold",
|
|
113
135
|
];
|
|
114
136
|
/** Intent numbers carry their own schema bounds (config.js), so the shared
|
|
115
137
|
* NUMBER_FIELDS coercion (which only rejects negatives) is too loose — these
|
|
@@ -125,7 +147,7 @@ const INTENT_NUMBER_FIELDS = [
|
|
|
125
147
|
/** Intent route references: unlike pool routes they round-trip the "unset"
|
|
126
148
|
* {provider:"", model:""} pair, so they validate against the loosened
|
|
127
149
|
* cleanIntentRoute (both keys must be strings; either may be empty). */
|
|
128
|
-
const INTENT_ROUTE_FIELDS = ["intentClassifierRoute", "intentFallbackRoute"];
|
|
150
|
+
const INTENT_ROUTE_FIELDS = ["intentClassifierRoute", "intentFallbackRoute", "trimSideModel", "trimFoldFallbackModel"];
|
|
129
151
|
/** Intent candidate list (route + optional effort/note per entry). */
|
|
130
152
|
const INTENT_CANDIDATE_FIELDS = ["intentCandidates"];
|
|
131
153
|
|
|
@@ -144,7 +166,10 @@ const ENUM_FIELDS = {
|
|
|
144
166
|
dispatchEnforce: ["off", "advice", "enforce"],
|
|
145
167
|
wmDenyMode: ["cap", "deny"],
|
|
146
168
|
intentClassifierKind: ["chat", "systemone"],
|
|
169
|
+
trimMode: ["off", "advisory", "auto"],
|
|
147
170
|
};
|
|
171
|
+
/** Trim string fields (mirrors host/config.js strings). */
|
|
172
|
+
const TRIM_STRING_FIELDS = ["trimStateDir"];
|
|
148
173
|
|
|
149
174
|
/** JSON response helper. */
|
|
150
175
|
function writeJson(res, status, payload) {
|
|
@@ -312,6 +337,7 @@ function cleanIntentCandidate(entry) {
|
|
|
312
337
|
export function snapshotOf(config) {
|
|
313
338
|
const values = {};
|
|
314
339
|
for (const field of STRING_FIELDS) values[field] = String(rawOf(config?.[field]) ?? "");
|
|
340
|
+
for (const field of TRIM_STRING_FIELDS) values[field] = String(rawOf(config?.[field]) ?? "");
|
|
315
341
|
for (const field of POOL_FIELDS)
|
|
316
342
|
values[field] = Array.isArray(rawOf(config?.[field]))
|
|
317
343
|
? rawOf(config[field]).map(cleanRoute).filter(Boolean)
|
|
@@ -337,6 +363,11 @@ export function snapshotOf(config) {
|
|
|
337
363
|
values.dispatchEnforce = ENUM_FIELDS.dispatchEnforce.includes(rawOf(config?.dispatchEnforce))
|
|
338
364
|
? rawOf(config.dispatchEnforce)
|
|
339
365
|
: "advice";
|
|
366
|
+
values.trimMode = ENUM_FIELDS.trimMode.includes(rawOf(config?.trimMode)) ? rawOf(config.trimMode) : "auto";
|
|
367
|
+
for (const { field, def } of TRIM_NUMBER_FIELDS) {
|
|
368
|
+
const raw = Number(rawOf(config?.[field]));
|
|
369
|
+
values[field] = Number.isFinite(raw) ? raw : def;
|
|
370
|
+
}
|
|
340
371
|
for (const field of NUMBER_FIELDS) {
|
|
341
372
|
const raw = Number(rawOf(config?.[field]));
|
|
342
373
|
values[field] = Number.isFinite(raw) ? raw : 0;
|
|
@@ -407,6 +438,17 @@ export function coerceValues(input) {
|
|
|
407
438
|
values.langScope = input.langScope;
|
|
408
439
|
if (!ENUM_FIELDS.dispatchEnforce.includes(input.dispatchEnforce)) return null;
|
|
409
440
|
values.dispatchEnforce = input.dispatchEnforce;
|
|
441
|
+
if (!ENUM_FIELDS.trimMode.includes(input.trimMode)) return null;
|
|
442
|
+
values.trimMode = input.trimMode;
|
|
443
|
+
for (const field of TRIM_STRING_FIELDS) {
|
|
444
|
+
if (typeof input[field] !== "string") return null;
|
|
445
|
+
values[field] = input[field];
|
|
446
|
+
}
|
|
447
|
+
for (const { field, min, max } of TRIM_NUMBER_FIELDS) {
|
|
448
|
+
const raw = Number(input[field]);
|
|
449
|
+
if (!Number.isFinite(raw) || raw < min || raw > max) return null;
|
|
450
|
+
values[field] = raw;
|
|
451
|
+
}
|
|
410
452
|
for (const field of NUMBER_FIELDS) {
|
|
411
453
|
const raw = Number(input[field]);
|
|
412
454
|
if (!Number.isFinite(raw) || raw < 0) return null;
|
|
@@ -421,9 +463,27 @@ export function coerceValues(input) {
|
|
|
421
463
|
return values;
|
|
422
464
|
}
|
|
423
465
|
|
|
424
|
-
/**
|
|
425
|
-
|
|
426
|
-
|
|
466
|
+
/** Stable JSON text: object keys sorted at every depth. snapshotOf and
|
|
467
|
+
* coerceValues build the same fields in DIFFERENT insertion orders
|
|
468
|
+
* (e.g. trimStateDir sits 7th in a snapshot but near the tail after
|
|
469
|
+
* coercion), so a raw stringify compared key order, not content —
|
|
470
|
+
* every fenced save 409'd ("settings changed elsewhere") even with no
|
|
471
|
+
* intervening writer (2026-11-07 settings-save bug). */
|
|
472
|
+
function canonicalJson(value) {
|
|
473
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
474
|
+
if (value !== null && typeof value === "object")
|
|
475
|
+
return `{${Object.keys(value)
|
|
476
|
+
.sort()
|
|
477
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
478
|
+
.join(",")}}`;
|
|
479
|
+
return JSON.stringify(value) ?? "undefined";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Deep-equal on the plain-JSON value shapes this module exchanges;
|
|
483
|
+
* key-order insensitive (see canonicalJson). Exported for the smoke
|
|
484
|
+
* suite alongside coerceValues — the fence predicate IS the contract. */
|
|
485
|
+
export function sameValues(a, b) {
|
|
486
|
+
return canonicalJson(a) === canonicalJson(b);
|
|
427
487
|
}
|
|
428
488
|
|
|
429
489
|
/** Enumerate the model catalog from the host session controller. */
|
package/host/teams.js
CHANGED
|
@@ -84,6 +84,7 @@ const TEAMS_SECTION_TEXT = [
|
|
|
84
84
|
"## 五、团队触发与协作纪律",
|
|
85
85
|
"- 需要多成员持久协作、共享任务板、消息往来的用 Agent Teams(满足任一即触发 spawn_teammate:可并行的多个独立子任务;量大且自包含;主上下文水位已高;需要角色分离);一次性、无需共享状态的单点调查用 subagent。",
|
|
86
86
|
"- 写作域隔离:共享任务上写明互不重叠的 write_scopes;用 team_task_* 维护任务板与依赖顺序;Lead 汇总最终 diff、复核关键改动并运行测试;用 wait_agent 等到必需的 teammate 完成,绝不在结果未回前下最终结论。",
|
|
87
|
+
"- 子智能体消息通道:subagent/workflow 返回的子会话 id 只能经 child_message 桥接送达——send_message 只解析 teammate 名字,对子会话 id 必报 active teammate「…」not found;该报错不代表子会话死亡,先改用 child_message,不要据此重派或判定死亡。",
|
|
87
88
|
].join("\n");
|
|
88
89
|
|
|
89
90
|
// ---- shared volatile-read helpers (mirrors dispatch.js) --------------------
|
|
@@ -580,4 +581,4 @@ function applyTeams(ctx, config) {
|
|
|
580
581
|
syncChildMessageTool();
|
|
581
582
|
}
|
|
582
583
|
|
|
583
|
-
export { applyTeams, pumpTeamsOrchestration, SWITCHMAN_SETTINGS_NS, scheduleTeamsOrchestration, teamsBundleState, teamsSyncSnapshot };
|
|
584
|
+
export { applyTeams, pumpTeamsOrchestration, SWITCHMAN_SETTINGS_NS, TEAMS_SECTION_TEXT, scheduleTeamsOrchestration, teamsBundleState, teamsSyncSnapshot };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-switchman",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
4
4
|
"description": "Autonomous Agent Teams doctrine for the four shipped DeepSeek Harness presets, plus language preferences, dispatch pools with model ranking, and context watermark control.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"icon": "./icon.svg",
|
|
29
29
|
"engines": {
|
|
30
30
|
"node": ">=24",
|
|
31
|
-
"dsh": ">=0.2.
|
|
31
|
+
"dsh": ">=0.2.2-rc.1"
|
|
32
32
|
},
|
|
33
33
|
"exports": {
|
|
34
34
|
".": "./index.js",
|