dsh-switchman 1.7.1 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +25 -4
- package/README.es.md +25 -4
- package/README.fr.md +25 -4
- package/README.it.md +25 -4
- package/README.ja.md +25 -4
- package/README.ko.md +25 -4
- package/README.md +25 -4
- package/README.pt.md +25 -4
- package/README.ru.md +25 -4
- package/README.zh-TW.md +25 -4
- package/README.zh.md +25 -4
- package/client.js +1916 -35
- package/host/config.js +93 -4
- package/host/context-watch.js +126 -58
- package/host/dispatch.js +132 -20
- package/host/intent-presets.js +163 -0
- package/host/intent.js +952 -0
- package/host/routes.js +158 -3
- package/host/vision.js +62 -26
- package/index.js +18 -2
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
package/host/config.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* - pool/rank dispatch pools and capability ranking (Phase 2)
|
|
13
13
|
* - wm* context watermark control (Phase 3)
|
|
14
14
|
* - teams* Agent Teams doctrine mode + whitelist sync (teams mode)
|
|
15
|
+
* - intent* intent-routing "auto" pseudo-model (classifier + candidates)
|
|
15
16
|
*
|
|
16
17
|
* Ported from opencode-switchman's opencode-switchman.jsonc surface
|
|
17
18
|
* (oc/src/config.ts:23-51, oc/src/types.ts:281-344) onto DSH settings.
|
|
@@ -44,6 +45,34 @@ export const EffortEntrySchema = z.object({
|
|
|
44
45
|
effort: z.string().min(1).required(),
|
|
45
46
|
});
|
|
46
47
|
|
|
48
|
+
/** One intent-routing candidate: a real model route the classifier may pick,
|
|
49
|
+
* plus an optional reasoning-effort pin and a capability note the classifier
|
|
50
|
+
* reads. Every row carries a stable id; preset rows (id owned by
|
|
51
|
+
* host/intent-presets.js) keep a code-owned immutable note, while enabled /
|
|
52
|
+
* provider / model / effort are user-configured — provider/model may be
|
|
53
|
+
* empty while a preset waits for its route. effort/note follow
|
|
54
|
+
* RankEntrySchema.tier's optional style (no `.required()`: schemastery
|
|
55
|
+
* passes undefined through, so entries may omit either key). */
|
|
56
|
+
export const IntentCandidateSchema = z.object({
|
|
57
|
+
id: z.string(),
|
|
58
|
+
preset: z.boolean(),
|
|
59
|
+
enabled: z.boolean(),
|
|
60
|
+
provider: z.string(),
|
|
61
|
+
model: z.string(),
|
|
62
|
+
effort: z.string(),
|
|
63
|
+
note: z.string(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** One intent-layer route reference (classifier or fallback). Unlike
|
|
67
|
+
* ModelRouteSchema this accepts the "unset" `{provider:"", model:""}`
|
|
68
|
+
* pair the settings page round-trips — ModelRouteSchema's `.min(1)`
|
|
69
|
+
* would reject that exact declared default on every settings write.
|
|
70
|
+
* Consumers validate non-empty before trusting the route. */
|
|
71
|
+
const IntentRouteSchema = z.object({
|
|
72
|
+
provider: z.string(),
|
|
73
|
+
model: z.string(),
|
|
74
|
+
});
|
|
75
|
+
|
|
47
76
|
/** All dsh-switchman settings. Field names are the settings mutation paths. */
|
|
48
77
|
export const Config = z.object({
|
|
49
78
|
// --- Phase 0: UI locale ------------------------------------------------
|
|
@@ -99,13 +128,13 @@ export const Config = z.object({
|
|
|
99
128
|
|
|
100
129
|
// --- Phase 3: context watermark control --------------------------------
|
|
101
130
|
/** Soft watermark: advisory banner + delegation nudge (tokens). */
|
|
102
|
-
wmSoftTokens: z.number().min(1000).default(
|
|
131
|
+
wmSoftTokens: z.number().min(1000).default(80000).volatile(),
|
|
103
132
|
/** Hard watermark: read budget tightens, wrap-up advised (tokens). */
|
|
104
|
-
wmHardTokens: z.number().min(2000).default(
|
|
133
|
+
wmHardTokens: z.number().min(2000).default(120000).volatile(),
|
|
105
134
|
/** Force watermark: backup + compaction handover (tokens). */
|
|
106
|
-
wmForceTokens: z.number().min(3000).default(
|
|
135
|
+
wmForceTokens: z.number().min(3000).default(150000).volatile(),
|
|
107
136
|
/** Per-call read budget for read/glob/grep (tokens). */
|
|
108
|
-
wmReadBudgetTokens: z.number().min(200).default(
|
|
137
|
+
wmReadBudgetTokens: z.number().min(200).default(3000).volatile(),
|
|
109
138
|
/** Hard-tier behavior: cap reads and advise, or deny outright. */
|
|
110
139
|
wmDenyMode: z.union(["cap", "deny"]).default("cap").volatile(),
|
|
111
140
|
/** Force tier triggers backup + compaction automatically. */
|
|
@@ -132,6 +161,66 @@ export const Config = z.object({
|
|
|
132
161
|
* wholesale. The whitelist only applies to top-level sessions composed
|
|
133
162
|
* afterwards. */
|
|
134
163
|
syncWhitelist: z.boolean().default(false).volatile(),
|
|
164
|
+
|
|
165
|
+
// --- Intent recognition: classifier-routed "auto" model ----------------
|
|
166
|
+
/** Master switch for intent routing (design: .dsh-switchman/plans/
|
|
167
|
+
* intent-routing.md §2). The adapter stays mounted either way; OFF
|
|
168
|
+
* only hides the catalog entry (listModels returns [], the selector
|
|
169
|
+
* drops the empty group) — a session already on Auto routes through
|
|
170
|
+
* the documented fallback chain (routedCurrent → intentFallbackRoute
|
|
171
|
+
* → first candidate) with NO classifier call. */
|
|
172
|
+
intentEnabled: z.boolean().default(false).volatile(),
|
|
173
|
+
/** Small-model route that classifies the conversation against the
|
|
174
|
+
* candidates. Empty = unset; classification is skipped (fail-open). */
|
|
175
|
+
intentClassifierRoute: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
|
|
176
|
+
/** Optional reasoning-effort pin for the classifier call ("" = adapter
|
|
177
|
+
* default). */
|
|
178
|
+
intentClassifierEffort: z.string().default("").volatile(),
|
|
179
|
+
/** Classifier channel; must be an explicit choice. "chat" calls the
|
|
180
|
+
* intentClassifierRoute above through its provider's standard chat
|
|
181
|
+
* API with a strict-JSON instruction. "systemone" = direct HTTP to a
|
|
182
|
+
* hand-configured ollama /v1/systemone decision endpoint — no
|
|
183
|
+
* provider, uses intentClassifierEndpoint +
|
|
184
|
+
* intentClassifierSystemoneModel. */
|
|
185
|
+
intentClassifierKind: z.union(["chat", "systemone"]).default("chat").volatile(),
|
|
186
|
+
/** Full URL of the ollama /v1/systemone endpoint for kind "systemone"
|
|
187
|
+
* (e.g. http://127.0.0.1:11434/v1/systemone). Empty = unset; that
|
|
188
|
+
* kind fails open to the fallback chain. */
|
|
189
|
+
intentClassifierEndpoint: z.string().default("").volatile(),
|
|
190
|
+
/** Model name sent to the decision endpoint for kind "systemone"
|
|
191
|
+
* (free text, e.g. tev1:4b-mxfp8). Independent of the llm provider
|
|
192
|
+
* catalog — no provider registration needed. Empty = unset; that
|
|
193
|
+
* kind fails open to the fallback chain. */
|
|
194
|
+
intentClassifierSystemoneModel: z.string().default("").volatile(),
|
|
195
|
+
/** Independent candidate list the "auto" pseudo-model may route to (NOT
|
|
196
|
+
* poolMain). Each entry: {id, preset?, enabled?, provider, model,
|
|
197
|
+
* effort?, note?}; readers merge the built-in presets from
|
|
198
|
+
* host/intent-presets.js (see mergeIntentCandidates). */
|
|
199
|
+
intentCandidates: z.array(IntentCandidateSchema).default([]).volatile(),
|
|
200
|
+
/** Provider prompt-cache reuse window: while the current route stays
|
|
201
|
+
* below the switch-confidence threshold, keep it for this many
|
|
202
|
+
* seconds since the last applied routing. */
|
|
203
|
+
intentCacheTtlSeconds: z.number().min(0).default(300).volatile(),
|
|
204
|
+
/** Classifier confidence (0-100) required to leave the current model. */
|
|
205
|
+
intentSwitchConfidence: z.number().min(0).max(100).default(80).volatile(),
|
|
206
|
+
/** Classification-call budget in ms; timeout/abort fails open to the
|
|
207
|
+
* fallback chain. */
|
|
208
|
+
intentTimeoutMs: z.number().min(200).default(60000).volatile(),
|
|
209
|
+
/** Recent user/assistant messages fed to the classifier. */
|
|
210
|
+
intentHistoryMessages: z.number().min(1).max(20).default(6).volatile(),
|
|
211
|
+
/** Total character budget for that conversation excerpt. */
|
|
212
|
+
intentHistoryMaxChars: z.number().min(200).max(20000).default(2400).volatile(),
|
|
213
|
+
/** Context window (tokens) declared by the "auto" pseudo-model. Purely
|
|
214
|
+
* advisory metadata: drives the usage popover and the auto-compaction
|
|
215
|
+
* pressure math for sessions routed through Auto. While wmAutoHandover
|
|
216
|
+
* is on, the effective declared value is floored at
|
|
217
|
+
* ceil(wmForceTokens / 0.8) so the smooth handover always fires before
|
|
218
|
+
* the 0.8×-window auto-compaction (see effectiveContextWindow in
|
|
219
|
+
* host/intent.js). */
|
|
220
|
+
intentContextWindow: z.number().min(1024).default(200000).volatile(),
|
|
221
|
+
/** Optional last-resort route when the classifier fails and no previous
|
|
222
|
+
* routed model exists; empty = the first candidate. */
|
|
223
|
+
intentFallbackRoute: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
|
|
135
224
|
});
|
|
136
225
|
|
|
137
226
|
/** The six dispatch lanes, in display order. */
|
package/host/context-watch.js
CHANGED
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
* src/index.ts:386-459 (banner rendering) and 2066-2110 (auto-handover with
|
|
5
5
|
* its anti-deadlock lesson) onto DSH services:
|
|
6
6
|
*
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
11
|
-
*
|
|
7
|
+
* - KV-cache invariant: the systemPrompt section 'switchman:watermark'
|
|
8
|
+
* (order 10500) carries only byte-stable protocol text (root/subagent
|
|
9
|
+
* variants, no live numbers), so the request head never changes
|
|
10
|
+
* mid-session; the live tier + bucketed used reading travel as the
|
|
11
|
+
* 'switchman:watermark' runtime context (ctx.systemPrompt.context,
|
|
12
|
+
* order 300, required) — DSH renders contexts into the tail-appended
|
|
13
|
+
* "Current runtime context" snapshot, which is emitted as a new trailing
|
|
14
|
+
* user message only when its text changes, preserving the cached prefix
|
|
15
|
+
* by construction. Root sessions get tiers ok (silent)/soft/hard/force
|
|
16
|
+
* plus a static paused line; subagent sessions get soft/hard/force with
|
|
17
|
+
* their own force cap. Both text functions are side-effect free.
|
|
12
18
|
* - Read budget double gate: `tools/post-execute` accounts read/glob/grep/
|
|
13
19
|
* bash result text (~len/3.5 tokens) per session per turn; `tools/
|
|
14
20
|
* pre-execute` denies or caps read/glob/grep once the budget is exhausted
|
|
@@ -133,9 +139,9 @@ function isRoot(ctx, agent) {
|
|
|
133
139
|
/** Live threshold snapshot from the volatile settings. */
|
|
134
140
|
function readThresholds(config) {
|
|
135
141
|
return {
|
|
136
|
-
soft: readNumber(config.wmSoftTokens,
|
|
137
|
-
hard: readNumber(config.wmHardTokens,
|
|
138
|
-
force: readNumber(config.wmForceTokens,
|
|
142
|
+
soft: readNumber(config.wmSoftTokens, 80000),
|
|
143
|
+
hard: readNumber(config.wmHardTokens, 120000),
|
|
144
|
+
force: readNumber(config.wmForceTokens, 150000),
|
|
139
145
|
};
|
|
140
146
|
}
|
|
141
147
|
|
|
@@ -150,64 +156,89 @@ function measureNow(ctx, agent) {
|
|
|
150
156
|
}
|
|
151
157
|
}
|
|
152
158
|
|
|
153
|
-
// ----
|
|
159
|
+
// ---- watermark rendering (pure given state + measurement) ------------------
|
|
154
160
|
|
|
155
|
-
/**
|
|
161
|
+
/** Bucket width for the live used reading. 5k steps keep the tail snapshot
|
|
162
|
+
* emission rate low (the snapshot re-emits only when its joined text
|
|
163
|
+
* changes); tier crossings change the line immediately regardless. */
|
|
164
|
+
const USED_BUCKET_TOKENS = 5000;
|
|
165
|
+
|
|
166
|
+
/** `~50k`-style bucketed reading for the live watermark line. */
|
|
167
|
+
function bucketKilo(tokens) {
|
|
168
|
+
return `${Math.round(tokens / USED_BUCKET_TOKENS) * (USED_BUCKET_TOKENS / 1000)}k`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Byte-stable protocol text for root sessions: no live numbers, so the
|
|
172
|
+
* request head stays byte-identical for the whole session (KV-cache
|
|
173
|
+
* invariant); the numbers live in the tail context line only. */
|
|
174
|
+
const PROTOCOL_ROOT_TEXT = [
|
|
175
|
+
"[SWITCHMAN:WATERMARK] protocol: this session is guarded by a context watermark measured every step.",
|
|
176
|
+
"ok tier is silent. soft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself. hard tier: stop broad reading (read/glob/grep are budget-limited and may be denied) and start wrapping up. force tier: MANDATORY wrap-up now — produce the final answer from what you already hold; an automatic context handover (backup + document + compaction + continuation) may run and will tell you to end the turn.",
|
|
177
|
+
"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.",
|
|
178
|
+
].join("\n");
|
|
179
|
+
|
|
180
|
+
/** Byte-stable protocol text for subagent sessions (same invariant). */
|
|
181
|
+
const PROTOCOL_SUB_TEXT = [
|
|
182
|
+
"[SWITCHMAN:WATERMARK] protocol: this subagent session is guarded by a context watermark measured every step.",
|
|
183
|
+
"ok tier is silent. soft tier: keep reads narrow and drop bulky raw output. hard tier: stop broad reading; wrap toward the final summary. force tier: stop reading and return the final summary now — end the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps.",
|
|
184
|
+
"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.",
|
|
185
|
+
].join("\n");
|
|
186
|
+
|
|
187
|
+
/** Static paused line (numbers omitted on purpose: enforcement is off, and a
|
|
188
|
+
* frozen string emits once and never churns the snapshot again). */
|
|
189
|
+
const PAUSED_LINE =
|
|
190
|
+
"[SWITCHMAN:WATERMARK] paused — measurement continues, but every watermark enforcement stays off until /ctx-resume.";
|
|
191
|
+
|
|
192
|
+
/** Root-session live line: "" at ok (token economy), tier advice lines plus
|
|
156
193
|
* the live handover progress (phase/attempt) and auto-cooldown state. */
|
|
157
|
-
function
|
|
194
|
+
function renderRootWatermark(level, used, th, status) {
|
|
195
|
+
if (level === "ok") return "";
|
|
158
196
|
const head =
|
|
159
|
-
`[SWITCHMAN:WATERMARK]
|
|
160
|
-
` budget
|
|
161
|
-
if (level === "
|
|
162
|
-
if (level === "
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
lines.push(`
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
? `automatic handover cooldown: ${status.cooldownMin} min left; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
|
|
180
|
-
: `an automatic context handover (backup + document + compaction + continuation) is armed and will fire at the next step boundary${status?.tool ? "; calling the ctx_handover tool triggers it immediately" : ""}.`,
|
|
181
|
-
);
|
|
182
|
-
} else {
|
|
183
|
-
lines.push(`run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to trigger a manual context handover.`);
|
|
184
|
-
}
|
|
197
|
+
`[SWITCHMAN:WATERMARK] ${level} tier — used ~${bucketKilo(used)}` +
|
|
198
|
+
` of budget ${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
|
|
199
|
+
if (level === "soft") return `${head}\nsoft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.`;
|
|
200
|
+
if (level === "hard") return `${head}\nhard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.`;
|
|
201
|
+
const lines = [head, "force tier: MANDATORY wrap-up now — produce the final answer from what you already hold."];
|
|
202
|
+
const phase = status?.phase;
|
|
203
|
+
if (phase === "backup") {
|
|
204
|
+
lines.push(`context handover running: forking the backup session and writing the handover document…`);
|
|
205
|
+
} else if (phase === "compacting") {
|
|
206
|
+
lines.push(`context handover running: compaction attempt ${status?.attempt ?? "?"} (retries while the turn is still running)…`);
|
|
207
|
+
} else if (phase === "continuation") {
|
|
208
|
+
lines.push(`context handover completing: waking this session to fill in the document and continue the task…`);
|
|
209
|
+
} else if (status?.autoHandover) {
|
|
210
|
+
lines.push(
|
|
211
|
+
status?.cooldownMin > 0
|
|
212
|
+
? `automatic handover cooldown: ${status.cooldownMin} min left; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
|
|
213
|
+
: `an automatic context handover (backup + document + compaction + continuation) is armed and will fire at the next step boundary${status?.tool ? "; calling the ctx_handover tool triggers it immediately" : ""}.`,
|
|
214
|
+
);
|
|
215
|
+
} else {
|
|
216
|
+
lines.push(`run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to trigger a manual context handover.`);
|
|
185
217
|
}
|
|
186
218
|
return lines.join("\n");
|
|
187
219
|
}
|
|
188
220
|
|
|
189
|
-
/** Subagent-session
|
|
190
|
-
* as the root
|
|
221
|
+
/** Subagent-session live line: the same soft/hard/force absolute-token tiers
|
|
222
|
+
* as the root line ("" while ok — token economy), with subagent-flavored
|
|
191
223
|
* advice and the HANDOFF block due at the force tier. Pausing never applies
|
|
192
224
|
* to subagents (oc index.ts:187-190). */
|
|
193
|
-
function
|
|
225
|
+
function renderSubWatermark(used, th) {
|
|
194
226
|
const level = levelOf(used, th);
|
|
195
|
-
const head =
|
|
196
|
-
`[SWITCHMAN:WATERMARK] subagent level=${level} used=${kilo(used)}` +
|
|
197
|
-
` budget=${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
|
|
198
227
|
if (level === "ok") return "";
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
228
|
+
const head =
|
|
229
|
+
`[SWITCHMAN:WATERMARK] subagent ${level} tier — used ~${bucketKilo(used)}` +
|
|
230
|
+
` of budget ${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
|
|
231
|
+
if (level === "soft") return `${head}\nsoft tier: keep reads narrow and drop bulky raw output.`;
|
|
232
|
+
if (level === "hard") return `${head}\nhard tier: stop broad reading; start wrapping toward the final summary.`;
|
|
203
233
|
return (
|
|
204
234
|
`${head}\nforce tier: stop reading and return the final summary now.\n` +
|
|
205
235
|
`End the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps.`
|
|
206
236
|
);
|
|
207
237
|
}
|
|
208
238
|
|
|
209
|
-
/** Whole
|
|
210
|
-
|
|
239
|
+
/** Whole live line for one assembly (the tail context); "" whenever anything
|
|
240
|
+
* needed is missing or the session sits at the silent ok tier. */
|
|
241
|
+
function renderWatermarkContext(ctx, config, state, agent) {
|
|
211
242
|
try {
|
|
212
243
|
const sessionId = sessionIdOf(agent);
|
|
213
244
|
if (sessionId === null || agent.session === undefined) return "";
|
|
@@ -220,16 +251,18 @@ function renderBanner(ctx, config, state, agent) {
|
|
|
220
251
|
// (enabled by the wmSubagentCap toggle, 0/0 = follow the root set).
|
|
221
252
|
const subForce = readBool(config.wmSubagentCap, true) ? readNumber(config.wmSubagentForceTokens, 0) : 0;
|
|
222
253
|
const subTh = subForce > 0 ? { ...th, force: subForce } : th;
|
|
223
|
-
return
|
|
254
|
+
return renderSubWatermark(used, subTh);
|
|
224
255
|
}
|
|
225
|
-
|
|
256
|
+
if (state.paused.has(sessionId)) return PAUSED_LINE;
|
|
226
257
|
const live = handoverOf(sessionId);
|
|
227
258
|
const elapsed = Date.now() - (state.lastHandover.get(sessionId) ?? 0);
|
|
228
|
-
|
|
259
|
+
// Cooldown quantized to 5-minute steps so the emitted line stays
|
|
260
|
+
// byte-stable between steps (snapshot re-emits only on change).
|
|
261
|
+
return renderRootWatermark(levelOf(used, th), used, th, {
|
|
229
262
|
autoHandover: readBool(config.wmAutoHandover, true),
|
|
230
263
|
phase: live?.phase,
|
|
231
264
|
attempt: live?.attempt,
|
|
232
|
-
cooldownMin: Math.max(0, Math.ceil((HANDOVER_COOLDOWN_MS - elapsed) /
|
|
265
|
+
cooldownMin: Math.max(0, Math.ceil((HANDOVER_COOLDOWN_MS - elapsed) / 300_000) * 5),
|
|
233
266
|
tool: state.handoverTool ?? undefined,
|
|
234
267
|
});
|
|
235
268
|
} catch {
|
|
@@ -298,13 +331,40 @@ function applyContextWatch(ctx, config) {
|
|
|
298
331
|
};
|
|
299
332
|
const warn = (message) => ctx.logger?.warn?.(`dsh-switchman: ${message}`);
|
|
300
333
|
|
|
301
|
-
// --- A. watermark
|
|
334
|
+
// --- A. watermark: byte-stable head protocol + live tail context --------
|
|
335
|
+
// KV-cache invariant (see the file header): everything before the
|
|
336
|
+
// conversation history must stay byte-identical for the whole session,
|
|
337
|
+
// so the head section carries only static protocol text while ALL live
|
|
338
|
+
// numbers travel as runtime context. DSH joins contexts into the
|
|
339
|
+
// "Current runtime context" snapshot and appends it at the tail as a new
|
|
340
|
+
// user message only when its text changes — append-only by construction,
|
|
341
|
+
// the cached prefix is never touched.
|
|
302
342
|
ctx.systemPrompt.section({
|
|
303
343
|
name: "switchman:watermark",
|
|
304
344
|
order: 10500,
|
|
305
345
|
interpolate: false,
|
|
306
|
-
text: (context) =>
|
|
346
|
+
text: (context) => (isRoot(ctx, context?.agent) ? PROTOCOL_ROOT_TEXT : PROTOCOL_SUB_TEXT),
|
|
307
347
|
});
|
|
348
|
+
// Capability check first: on a host without the runtime-context API an
|
|
349
|
+
// optional chain would short-circuit silently — the live watermark line
|
|
350
|
+
// would vanish with no log at all. Warn once so it stays diagnosable.
|
|
351
|
+
if (typeof ctx.systemPrompt?.context !== "function") {
|
|
352
|
+
warn("systemPrompt.context API unavailable on this DSH host — live watermark line disabled (static head protocol remains)");
|
|
353
|
+
} else {
|
|
354
|
+
try {
|
|
355
|
+
ctx.systemPrompt.context({
|
|
356
|
+
name: "switchman:watermark",
|
|
357
|
+
// After the core placements (WORKING_DIRECTORY 100 … WEB_SURFACE
|
|
358
|
+
// 210), so the watermark line closes the snapshot.
|
|
359
|
+
order: 300,
|
|
360
|
+
required: true,
|
|
361
|
+
interpolate: false,
|
|
362
|
+
text: (context) => renderWatermarkContext(ctx, config, state, context?.agent),
|
|
363
|
+
});
|
|
364
|
+
} catch (error) {
|
|
365
|
+
warn(`watermark context registration failed: ${error?.message ?? error}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
308
368
|
|
|
309
369
|
// --- C. handover execution: borrow the session's own /compact -----------
|
|
310
370
|
// The desktop profile provides compaction ONLY inside each preset's
|
|
@@ -805,7 +865,7 @@ function applyContextWatch(ctx, config) {
|
|
|
805
865
|
if (sessionId !== null && ACCOUNT_TOOLS.has(exec?.name ?? "")) {
|
|
806
866
|
const entry = accountRead(state, sessionId, resultTokens(result));
|
|
807
867
|
if (GATED_TOOLS.has(exec.name) && !state.paused.has(sessionId)) {
|
|
808
|
-
const budget = readNumber(config.wmReadBudgetTokens,
|
|
868
|
+
const budget = readNumber(config.wmReadBudgetTokens, 3000);
|
|
809
869
|
const ceiling = budget * CEILING_MULTIPLE;
|
|
810
870
|
const used = measureNow(ctx, exec.agent);
|
|
811
871
|
const th = readThresholds(config);
|
|
@@ -841,7 +901,7 @@ function applyContextWatch(ctx, config) {
|
|
|
841
901
|
if (!GATED_TOOLS.has(exec?.name ?? "")) return next();
|
|
842
902
|
const sessionId = sessionIdOf(exec?.agent);
|
|
843
903
|
if (sessionId === null || state.paused.has(sessionId)) return next();
|
|
844
|
-
const budget = readNumber(config.wmReadBudgetTokens,
|
|
904
|
+
const budget = readNumber(config.wmReadBudgetTokens, 3000);
|
|
845
905
|
if (turnUsed(state, sessionId) < budget * CEILING_MULTIPLE) return next();
|
|
846
906
|
const used = measureNow(ctx, exec.agent);
|
|
847
907
|
if (used === null) return next();
|
|
@@ -957,4 +1017,12 @@ function applyContextWatch(ctx, config) {
|
|
|
957
1017
|
}
|
|
958
1018
|
}
|
|
959
1019
|
|
|
960
|
-
export {
|
|
1020
|
+
export {
|
|
1021
|
+
applyContextWatch,
|
|
1022
|
+
bucketKilo,
|
|
1023
|
+
PAUSED_LINE,
|
|
1024
|
+
PROTOCOL_ROOT_TEXT,
|
|
1025
|
+
PROTOCOL_SUB_TEXT,
|
|
1026
|
+
renderRootWatermark,
|
|
1027
|
+
renderSubWatermark,
|
|
1028
|
+
};
|
package/host/dispatch.js
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
* `tools/pre-execute` waterfall listener denies `subagent` calls that name
|
|
8
8
|
* a provider+model outside every configured pool and modelRank.
|
|
9
9
|
*
|
|
10
|
+
* The same listener also runs an annotation sanitation pass in EVERY mode:
|
|
11
|
+
* a model id copied verbatim off the rendered table can embed its
|
|
12
|
+
* display-only `(TIER)`/`@effort` annotations (the recommendation table is
|
|
13
|
+
* the one string an LLM is most tempted to copy), which DSH's exact-match
|
|
14
|
+
* child-route whitelist then rejects with a generic binding error that
|
|
15
|
+
* pushes the caller back to implicit dispatch — silently losing the lane's
|
|
16
|
+
* route. The sanitation denies such calls with the exact corrected
|
|
17
|
+
* arguments instead (exec.arguments is deep-frozen by the runtime, so a
|
|
18
|
+
* deny is the only correction channel `tools/pre-execute` offers).
|
|
19
|
+
*
|
|
10
20
|
* Model-facing text is English and byte-stable for a given configuration
|
|
11
21
|
* (the anchor line never varies with UI locale). All reads go through the
|
|
12
22
|
* live volatile references, so settings edits apply without a plugin
|
|
@@ -177,6 +187,103 @@ function effortMapOf(pool) {
|
|
|
177
187
|
return effortOf;
|
|
178
188
|
}
|
|
179
189
|
|
|
190
|
+
/** Trailing display annotations renderPoolsBlock appends to a pool entry:
|
|
191
|
+
* the `(S)/(A)/(B)/(C)` capability-grade parenthetical (uppercase only —
|
|
192
|
+
* that is what renderRoute emits), the `@effort` pin, and the
|
|
193
|
+
* `⚠not-authorized` drift marker. */
|
|
194
|
+
const TRAILING_EFFORT = /@([A-Za-z0-9._-]+)$/;
|
|
195
|
+
const TRAILING_TIER = /\([SABC]\)$/;
|
|
196
|
+
const TRAILING_MARKER = /\s*⚠not-authorized$/u;
|
|
197
|
+
|
|
198
|
+
/** Strip every trailing display annotation from a model id
|
|
199
|
+
* ("m(A)@high" → "m"), looped so any order reduces; an unannotated id
|
|
200
|
+
* round-trips unchanged. */
|
|
201
|
+
function stripTrailingAnnotations(model) {
|
|
202
|
+
let s = model.trim();
|
|
203
|
+
for (;;) {
|
|
204
|
+
const next = s.replace(TRAILING_MARKER, "").replace(TRAILING_EFFORT, "").replace(TRAILING_TIER, "").trim();
|
|
205
|
+
if (next === s) return s;
|
|
206
|
+
s = next;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Folded-key → first configured {provider, model} route over every lane
|
|
211
|
+
* and modelRank — the enforce gate's matcher, factored out so the
|
|
212
|
+
* sanitation pass shares it. Empty when nothing is configured. */
|
|
213
|
+
function configuredRoutes(config) {
|
|
214
|
+
const routes = new Map();
|
|
215
|
+
const add = (route) => {
|
|
216
|
+
if (!isModelRoute(route)) return;
|
|
217
|
+
const key = normalizeModelKey(route.provider, route.model);
|
|
218
|
+
if (key !== "" && !routes.has(key)) routes.set(key, { provider: route.provider, model: route.model });
|
|
219
|
+
};
|
|
220
|
+
for (const lane of LANES) for (const route of readList(config[laneField(lane)])) add(route);
|
|
221
|
+
for (const entry of readList(config.modelRank)) add(entry);
|
|
222
|
+
return routes;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** First effort pin configured for one folded route key, across every
|
|
226
|
+
* lane's effort list (null when the route carries no pin) — completes a
|
|
227
|
+
* corrective hint whose copied id kept only the (TIER) annotation. */
|
|
228
|
+
function configuredEffortPin(config, key) {
|
|
229
|
+
for (const lane of LANES) {
|
|
230
|
+
for (const entry of readList(config[laneEffortsField(lane)])) {
|
|
231
|
+
if (!isEffortEntry(entry)) continue;
|
|
232
|
+
if (normalizeModelKey(entry.provider, entry.model) === key) return entry.effort;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Corrective deny reason for one delegation call whose model argument
|
|
239
|
+
* embeds pool-table display annotations, or null to pass through. The
|
|
240
|
+
* stripped id must match a configured route (folded, like the enforce
|
|
241
|
+
* gate) — an unknown id is left to DSH's own route validation, so an
|
|
242
|
+
* exotic-but-real id that merely looks annotated never gets denied.
|
|
243
|
+
* Covers the plain suffix copy ("m(A)", "m(A)@high"), the whole-cell copy
|
|
244
|
+
* including the ⚠not-authorized drift marker, and the provider-combined
|
|
245
|
+
* copy ("provider/m(A)" with provider left empty). The reasoning_effort
|
|
246
|
+
* hint rides along when the copied string carried the @effort annotation
|
|
247
|
+
* or the matched route holds a configured pin, and the call did not
|
|
248
|
+
* already set reasoning_effort itself. */
|
|
249
|
+
function annotationFixOf(args, config) {
|
|
250
|
+
const model = typeof args?.model === "string" ? args.model.trim() : "";
|
|
251
|
+
if (model === "") return null;
|
|
252
|
+
const providerGiven = typeof args?.provider === "string" && args.provider.trim() !== "";
|
|
253
|
+
let provider = providerGiven ? args.provider.trim() : "";
|
|
254
|
+
let modelPart = model;
|
|
255
|
+
if (!providerGiven && modelPart.includes("/")) {
|
|
256
|
+
const cut = modelPart.lastIndexOf("/");
|
|
257
|
+
provider = modelPart.slice(0, cut).trim();
|
|
258
|
+
modelPart = modelPart.slice(cut + 1).trim();
|
|
259
|
+
}
|
|
260
|
+
modelPart = modelPart.replace(TRAILING_MARKER, "").trim();
|
|
261
|
+
const effortMatch = TRAILING_EFFORT.exec(modelPart);
|
|
262
|
+
const stripped = stripTrailingAnnotations(modelPart);
|
|
263
|
+
if (stripped === modelPart || stripped === "") return null;
|
|
264
|
+
const key = normalizeModelKey(provider, stripped);
|
|
265
|
+
const matched = key === "" ? undefined : configuredRoutes(config).get(key);
|
|
266
|
+
if (matched === undefined) return null;
|
|
267
|
+
// The re-dispatch must name BOTH fields whenever the original call left
|
|
268
|
+
// provider empty (provider+model go together on these tools), even when
|
|
269
|
+
// the provider was recoverable from a combined "provider/model(…)" copy.
|
|
270
|
+
// A provider-given whole-cell copy still carries its "provider/" prefix
|
|
271
|
+
// inside the stripped id — the matched route's clean model is the only
|
|
272
|
+
// suggestion DSH's exact-match whitelist will actually accept.
|
|
273
|
+
const modelFix = stripped.includes("/") ? matched.model : stripped;
|
|
274
|
+
const fields = providerGiven
|
|
275
|
+
? `model "${modelFix}"`
|
|
276
|
+
: `provider "${provider !== "" ? provider : matched.provider}", model "${modelFix}"`;
|
|
277
|
+
const hasExplicitEffort = typeof args?.reasoning_effort === "string" && args.reasoning_effort.trim() !== "";
|
|
278
|
+
const pinned = effortMatch !== null ? effortMatch[1] : configuredEffortPin(config, key);
|
|
279
|
+
const effort = hasExplicitEffort || pinned === null ? "" : ` and reasoning_effort "${pinned}"`;
|
|
280
|
+
return (
|
|
281
|
+
`[SWITCHMAN:POOLS] the model argument "${args.model}" embeds pool-table display annotations — ` +
|
|
282
|
+
`the (S/A/B/C) capability grade and any @effort suffix are annotations, NOT parts of the model id. ` +
|
|
283
|
+
`Re-dispatch this call with ${fields}${effort}, keeping every other argument unchanged.`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
180
287
|
/** The `[SWITCHMAN:POOLS]` block: anchor line, one line per configured lane,
|
|
181
288
|
* then the guidance lines (byte-stable for a given configuration). Routes
|
|
182
289
|
* outside the session's DSH-authorized child models carry a ⚠not-authorized
|
|
@@ -207,7 +314,8 @@ function renderPoolsBlock(state) {
|
|
|
207
314
|
`When delegating via subagent/workflow, pick a model from the lane matching the task category; spawn_teammate takes no model argument (lanes do not apply to it).`,
|
|
208
315
|
`Lanes marked * are in manual order: dispatch follows the listed order exactly (the head is the first choice).`,
|
|
209
316
|
`effort: economy=medium mechanical=medium main=medium (high for complex tasks) hard=high vision=multimodal-capable model review=high; an unconfigured lane uses the system default model.`,
|
|
210
|
-
`A
|
|
317
|
+
`Entries render as provider/model(TIER)@effort: (TIER) is the S/A/B/C capability grade and @effort a manually pinned reasoning effort — both are display-only annotations, NEVER parts of the model id.`,
|
|
318
|
+
`Decompose before dispatching: provider/model(TIER)@effort → provider=provider, model=model, reasoning_effort=effort (a pin overrides the lane default); a route without @effort carries no per-route pin — fall back to the lane's default effort above.`,
|
|
211
319
|
`Review delegations: anchor independence on the code AUTHOR's model, not the main session's (author = the agent that produced the diff: its explicit dispatch route, or the inherited session model when unspecified). Prefer a reviewer model different from the author's; also avoiding the session model is a bonus (its integration pass already covers that angle). If no pool entry differs from the author, same-model review is allowed — declare DOWNGRADED and state whether it is relative to the author or the session.`,
|
|
212
320
|
`Explicit provider/model/effort on subagent/workflow calls is validated against this session's DSH-authorized child models (DSH Settings: allow agents to choose subagent models) — NOT against this table; when denied, discover allowed routes via list_subagent_models, or dispatch implicitly by omitting all three fields (the child then uses configured defaults or inherits the parent route).`,
|
|
213
321
|
);
|
|
@@ -253,34 +361,38 @@ function applyDispatch(ctx, config) {
|
|
|
253
361
|
},
|
|
254
362
|
});
|
|
255
363
|
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
364
|
+
// Annotation sanitation (every mode) + enforce gate (enforce mode only).
|
|
365
|
+
// Both are tools/pre-execute waterfall listeners in one registration:
|
|
366
|
+
// deny decisions are returned directly WITHOUT calling next() —
|
|
367
|
+
// { kind: "deny", reason } — while every other path must call and
|
|
368
|
+
// return next(). The sanitation covers subagent and subagent_fork; the
|
|
369
|
+
// enforce gate below stays subagent-only with both provider and model
|
|
370
|
+
// named; model-less calls use the system default and pass through.
|
|
261
371
|
ctx.on("tools/pre-execute", (exec, next) => {
|
|
262
372
|
try {
|
|
373
|
+
if (exec?.name !== "subagent" && exec?.name !== "subagent_fork") return next();
|
|
374
|
+
const args = exec.arguments;
|
|
375
|
+
|
|
376
|
+
// Annotation sanitation: a model id copied verbatim off the
|
|
377
|
+
// rendered table embeds its display-only (TIER)/@effort/⚠
|
|
378
|
+
// annotations; DSH's exact-match whitelist would reject it with
|
|
379
|
+
// a generic binding error that pushes the caller back to
|
|
380
|
+
// implicit dispatch, losing the lane's route. Deny with the
|
|
381
|
+
// exact corrected arguments instead — one immediate retry on
|
|
382
|
+
// the intended route.
|
|
383
|
+
const fix = annotationFixOf(args, config);
|
|
384
|
+
if (fix !== null) return { kind: "deny", reason: fix };
|
|
385
|
+
|
|
386
|
+
// Enforce gate: unchanged semantics, now sharing the factored
|
|
387
|
+
// configuredRoutes matcher (same folded keys as before).
|
|
263
388
|
if (readField(config.dispatchEnforce) !== "enforce") return next();
|
|
264
389
|
if (exec?.name !== "subagent") return next();
|
|
265
|
-
const args = exec.arguments;
|
|
266
390
|
if (typeof args?.provider !== "string" || args.provider === "") return next();
|
|
267
391
|
if (typeof args?.model !== "string" || args.model === "") return next();
|
|
268
392
|
const key = normalizeModelKey(args.provider, args.model);
|
|
269
393
|
if (key === "") return next();
|
|
270
394
|
|
|
271
|
-
const allowed =
|
|
272
|
-
for (const lane of LANES) {
|
|
273
|
-
for (const route of readList(config[laneField(lane)])) {
|
|
274
|
-
if (!isModelRoute(route)) continue;
|
|
275
|
-
const routeKey = normalizeModelKey(route.provider, route.model);
|
|
276
|
-
if (routeKey !== "") allowed.add(routeKey);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
for (const entry of readList(config.modelRank)) {
|
|
280
|
-
if (!isModelRoute(entry)) continue;
|
|
281
|
-
const rankKey = normalizeModelKey(entry.provider, entry.model);
|
|
282
|
-
if (rankKey !== "") allowed.add(rankKey);
|
|
283
|
-
}
|
|
395
|
+
const allowed = configuredRoutes(config);
|
|
284
396
|
if (allowed.size === 0 || allowed.has(key)) return next();
|
|
285
397
|
|
|
286
398
|
const state = readDispatchState(ctx, config);
|