dsh-switchman 1.8.0 → 1.9.0
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 +41 -1
- package/README.es.md +41 -1
- package/README.fr.md +41 -1
- package/README.it.md +41 -1
- package/README.ja.md +41 -1
- package/README.ko.md +41 -1
- package/README.md +41 -1
- package/README.pt.md +41 -1
- package/README.ru.md +41 -1
- package/README.zh-TW.md +41 -1
- package/README.zh.md +41 -1
- package/client.js +399 -2
- package/cordis.patch.yml +32 -0
- package/host/config.js +40 -0
- package/host/context-trim/fork.js +168 -0
- package/host/context-trim/index.js +830 -0
- package/host/context-trim/metrics.js +104 -0
- package/host/context-trim/state.js +241 -0
- package/host/context-trim/summarizer.js +179 -0
- package/host/context-trim/trigger.js +122 -0
- package/host/context-trim/view.js +316 -0
- package/host/context-watch.js +22 -2
- package/host/dispatch.js +132 -20
- package/host/subagent-model.js +1 -1
- package/index.js +15 -1
- package/package.json +4 -1
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/** dsh-switchman context-trim view builder (pure).
|
|
2
|
+
*
|
|
3
|
+
* The outbound-view half of the context-trim port (design:
|
|
4
|
+
* .dsh-switchman/analysis/context-trim-port-design.md §4): folds stored in
|
|
5
|
+
* a session ledger replace contiguous message ranges with ONE synthetic
|
|
6
|
+
* user message each. Everything here is pure and deterministic — the same
|
|
7
|
+
* (messages, ledger) pair must render a byte-identical view on every call
|
|
8
|
+
* (the golden test pins this), because a stable serialized prefix between
|
|
9
|
+
* folds is exactly what keeps provider prompt-cache hits alive.
|
|
10
|
+
*
|
|
11
|
+
* Message refs: messages at the llm/stream boundary are plain JSON-ish
|
|
12
|
+
* objects ({id?, role, content blocks / text}). An explicit id is trusted
|
|
13
|
+
* only when it looks addressable (short, no whitespace/control chars — the
|
|
14
|
+
* same validation billion-context applies); otherwise the ref is derived as
|
|
15
|
+
* a content hash of the canonicalized message (stable key order, volatile
|
|
16
|
+
* fields dropped), which survives the full-history re-send the agent loop
|
|
17
|
+
* performs every turn. Fold blocks anchor on refs, so a ledger keeps
|
|
18
|
+
* applying across process restarts without a parallel session log.
|
|
19
|
+
*
|
|
20
|
+
* Fail-open: blocks whose refs cannot be located (history rewritten by a
|
|
21
|
+
* racing compaction, content mutated) or that would reach into the
|
|
22
|
+
* protected tail are skipped, never guessed at — a stale ledger can only
|
|
23
|
+
* degrade to "no fold applied", never to lost messages.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
|
|
28
|
+
/** Cheap char → token estimate (the brief's char/4 heuristic). */
|
|
29
|
+
export const TOKENS_PER_CHAR = 1 / 4;
|
|
30
|
+
|
|
31
|
+
/** Object keys dropped before hashing (volatility would churn refs). */
|
|
32
|
+
const VOLATILE_KEYS = new Set(["ts", "time", "timestamp", "createdAt", "updatedAt", "receivedAt", "sentAt"]);
|
|
33
|
+
|
|
34
|
+
/** Canonicalization depth cap (well beyond any real message nesting). */
|
|
35
|
+
const MAX_CANON_DEPTH = 8;
|
|
36
|
+
|
|
37
|
+
/** Canonical JSON of one value: object keys sorted, volatile keys dropped,
|
|
38
|
+
* arrays kept in order. Used ONLY for ref hashing, never for output. */
|
|
39
|
+
function canonicalValue(value, depth) {
|
|
40
|
+
if (value === null || typeof value !== "object") {
|
|
41
|
+
if (typeof value === "bigint") return String(value);
|
|
42
|
+
if (typeof value === "number" && !Number.isFinite(value)) return null;
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
if (depth >= MAX_CANON_DEPTH) return "[depth]";
|
|
46
|
+
if (Array.isArray(value)) return value.map((item) => canonicalValue(item, depth + 1));
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const key of Object.keys(value).sort()) {
|
|
49
|
+
if (VOLATILE_KEYS.has(key)) continue;
|
|
50
|
+
out[key] = canonicalValue(value[key], depth + 1);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Canonical JSON text of one message (ref-hash input). */
|
|
56
|
+
function canonicalizeMessage(message) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.stringify(canonicalValue(message ?? null, 0)) ?? "null";
|
|
59
|
+
} catch {
|
|
60
|
+
return String(message);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True when an explicit message id is safe to address directly. */
|
|
65
|
+
function usableId(id) {
|
|
66
|
+
if (typeof id !== "string" || id === "" || id.length > 512) return false;
|
|
67
|
+
for (const char of id) {
|
|
68
|
+
const code = char.charCodeAt(0);
|
|
69
|
+
if (code <= 0x20 || code === 0x7f) return false;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Stable ref of one message: the explicit id when usable, else a short
|
|
75
|
+
* content hash (prefixed "h" so the two families can be told apart in
|
|
76
|
+
* logs). Index is unused for hashing — refs are position-independent on
|
|
77
|
+
* purpose (the host may prepend/insert nothing, but ids may be absent). */
|
|
78
|
+
export function refOf(message, _index) {
|
|
79
|
+
const id = message?.id;
|
|
80
|
+
if (usableId(id)) return id;
|
|
81
|
+
return `h${createHash("sha256").update(canonicalizeMessage(message)).digest("hex").slice(0, 12)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** ref -> ascending index list for one messages array. */
|
|
85
|
+
export function refPositions(messages) {
|
|
86
|
+
const positions = new Map();
|
|
87
|
+
messages.forEach((message, index) => {
|
|
88
|
+
const ref = refOf(message, index);
|
|
89
|
+
const list = positions.get(ref);
|
|
90
|
+
if (list === undefined) positions.set(ref, [index]);
|
|
91
|
+
else list.push(index);
|
|
92
|
+
});
|
|
93
|
+
return positions;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Estimated token count of one message (text content + tool-call payloads
|
|
97
|
+
* + small per-message framing overhead). Never throws. */
|
|
98
|
+
export function messageTokens(message) {
|
|
99
|
+
let chars = 8;
|
|
100
|
+
const content = message?.content;
|
|
101
|
+
try {
|
|
102
|
+
if (typeof content === "string") chars += content.length;
|
|
103
|
+
else if (Array.isArray(content)) {
|
|
104
|
+
for (const block of content) {
|
|
105
|
+
if (block !== null && typeof block === "object" && typeof block.text === "string") chars += block.text.length;
|
|
106
|
+
else chars += JSON.stringify(block ?? null).length;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(message?.tool_calls)) chars += JSON.stringify(message.tool_calls).length;
|
|
110
|
+
} catch {
|
|
111
|
+
/* unserializable block: keep the framing estimate */
|
|
112
|
+
}
|
|
113
|
+
return Math.max(1, Math.round(chars * TOKENS_PER_CHAR));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Estimated token count of a message range (inclusive indices). */
|
|
117
|
+
export function estimateTokensOf(messages, startIdx = 0, endIdx = messages.length - 1) {
|
|
118
|
+
let total = 0;
|
|
119
|
+
for (let i = Math.max(0, startIdx); i <= Math.min(messages.length - 1, endIdx); i++) total += messageTokens(messages[i]);
|
|
120
|
+
return total;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- tool-pair segmentation -------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/** True when the message OPENS tool calls (either wire shape). */
|
|
126
|
+
function opensToolCalls(message) {
|
|
127
|
+
if (message?.role !== "assistant") return false;
|
|
128
|
+
if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return true;
|
|
129
|
+
if (Array.isArray(message.content)) return message.content.some((block) => block?.type === "tool_use");
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** True when the message CARRIES tool results (either wire shape). */
|
|
134
|
+
function carriesToolResults(message) {
|
|
135
|
+
if (message?.role === "tool") return true;
|
|
136
|
+
if (Array.isArray(message.content)) return message.content.some((block) => block?.type === "tool_result");
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Atomic segments of a messages array: an assistant message that opens
|
|
141
|
+
* tool calls plus every directly following tool-result message form ONE
|
|
142
|
+
* segment (they must live or fold together — a split pair is rejected by
|
|
143
|
+
* the upstream API, dsh-compaction/lib/index.js:557 validates the same
|
|
144
|
+
* rule); every other message is its own segment. Returns [start, end]
|
|
145
|
+
* index pairs covering the array in order. */
|
|
146
|
+
export function segmentBounds(messages) {
|
|
147
|
+
const bounds = [];
|
|
148
|
+
let i = 0;
|
|
149
|
+
while (i < messages.length) {
|
|
150
|
+
let end = i;
|
|
151
|
+
if (opensToolCalls(messages[i])) {
|
|
152
|
+
let j = i + 1;
|
|
153
|
+
while (j < messages.length && carriesToolResults(messages[j])) j++;
|
|
154
|
+
end = j - 1;
|
|
155
|
+
}
|
|
156
|
+
bounds.push([i, end]);
|
|
157
|
+
i = end + 1;
|
|
158
|
+
}
|
|
159
|
+
return bounds;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** True when [startIdx, endIdx] aligns with segment boundaries (a fold
|
|
163
|
+
* range must never cut inside a tool pair). */
|
|
164
|
+
export function rangeIsSegmentAligned(messages, startIdx, endIdx) {
|
|
165
|
+
if (!Number.isInteger(startIdx) || !Number.isInteger(endIdx) || startIdx < 0 || endIdx < startIdx) return false;
|
|
166
|
+
// The range must START at a segment start and END at a segment end (it may
|
|
167
|
+
// span any number of whole segments — segments tile the array contiguously,
|
|
168
|
+
// so aligned ends guarantee no tool pair is cut).
|
|
169
|
+
let startHit = false;
|
|
170
|
+
for (const [start, end] of segmentBounds(messages)) {
|
|
171
|
+
if (start === startIdx) startHit = true;
|
|
172
|
+
if (end === endIdx) return startHit && start <= endIdx;
|
|
173
|
+
if (start > endIdx) break;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---- fold blocks ------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/** Deterministic wire text of one fold block's synthetic message. */
|
|
181
|
+
export function renderSummaryText(block) {
|
|
182
|
+
return `[SWITCHMAN:TRIM] ${block.summary}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The ONE synthetic message replacing a folded range (brief: synthetic
|
|
186
|
+
* user message; id carries the acp_summary_ prefix downstream tooling
|
|
187
|
+
* skips). Pure — same block, same bytes. */
|
|
188
|
+
export function summaryMessageOf(block) {
|
|
189
|
+
return {
|
|
190
|
+
id: `acp_summary_${block.id}`,
|
|
191
|
+
role: "user",
|
|
192
|
+
content: [{ type: "text", text: renderSummaryText(block) }],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Locate a block's range in a positions map at/after afterIdx. Strict:
|
|
197
|
+
* both endpoint refs must resolve and the located span must equal the
|
|
198
|
+
* block's stored message count (guards against re-sent histories that no
|
|
199
|
+
* longer match the folded range). Returns [firstIdx, lastIdx] or null. */
|
|
200
|
+
function locateBlock(positions, block, afterIdx) {
|
|
201
|
+
const range = block?.refRange;
|
|
202
|
+
if (range === null || typeof range !== "object") return null;
|
|
203
|
+
const firstList = positions.get(range.firstId);
|
|
204
|
+
const lastList = positions.get(range.lastId);
|
|
205
|
+
if (firstList === undefined || lastList === undefined) return null;
|
|
206
|
+
const anchored = Number.isInteger(block.count) && block.count > 0;
|
|
207
|
+
let firstIdx = null;
|
|
208
|
+
let lastIdx = null;
|
|
209
|
+
for (const start of firstList) {
|
|
210
|
+
if (start <= afterIdx) continue;
|
|
211
|
+
if (anchored) {
|
|
212
|
+
// Duplicate-content messages share one content-addressed ref, so
|
|
213
|
+
// resolve the pair anchored on the stored count, not on extremes.
|
|
214
|
+
const end = start + block.count - 1;
|
|
215
|
+
if (!lastList.includes(end)) continue;
|
|
216
|
+
firstIdx = start;
|
|
217
|
+
lastIdx = end;
|
|
218
|
+
} else {
|
|
219
|
+
firstIdx = start;
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
if (firstIdx === null) return null;
|
|
224
|
+
if (lastIdx === null) {
|
|
225
|
+
for (const index of lastList) {
|
|
226
|
+
if (index >= firstIdx) {
|
|
227
|
+
lastIdx = index;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (lastIdx === null) return null;
|
|
232
|
+
}
|
|
233
|
+
const span = lastIdx - firstIdx + 1;
|
|
234
|
+
const expected = anchored ? block.count : span;
|
|
235
|
+
return span === expected ? [firstIdx, lastIdx] : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Build the outbound view: apply every applicable fold block to a copy of
|
|
239
|
+
* the messages array. Pure (never mutates the input; the assembly splices
|
|
240
|
+
* the result into the live request array). Blocks apply in positional
|
|
241
|
+
* order; a block that cannot be located, overlaps an already-applied one,
|
|
242
|
+
* or reaches into the last protectedTail messages is skipped. Returns
|
|
243
|
+
* { messages, applied, foldedMessages } (foldedMessages = live messages
|
|
244
|
+
* the applied blocks replaced). */
|
|
245
|
+
export function buildView(messages, ledger, options = {}) {
|
|
246
|
+
const protectedTail = Number.isInteger(options.protectedTail) && options.protectedTail > 0 ? options.protectedTail : 0;
|
|
247
|
+
const blocks = Array.isArray(ledger?.blocks) ? ledger.blocks : [];
|
|
248
|
+
const positions = refPositions(messages);
|
|
249
|
+
const located = [];
|
|
250
|
+
let unlocated = 0;
|
|
251
|
+
for (const block of blocks) {
|
|
252
|
+
const range = locateBlock(positions, block, -1);
|
|
253
|
+
if (range !== null) located.push({ block, firstIdx: range[0], lastIdx: range[1] });
|
|
254
|
+
else unlocated += 1;
|
|
255
|
+
}
|
|
256
|
+
located.sort((a, b) => a.firstIdx - b.firstIdx || b.lastIdx - a.lastIdx);
|
|
257
|
+
const out = [];
|
|
258
|
+
let at = 0;
|
|
259
|
+
let applied = 0;
|
|
260
|
+
let foldedMessages = 0;
|
|
261
|
+
for (const entry of located) {
|
|
262
|
+
if (entry.firstIdx < at) continue; // overlaps an applied block — skip
|
|
263
|
+
if (protectedTail > 0 && entry.lastIdx >= messages.length - protectedTail) continue;
|
|
264
|
+
if (!rangeIsSegmentAligned(messages, entry.firstIdx, entry.lastIdx)) continue; // never split a tool pair (design §4)
|
|
265
|
+
for (let i = at; i < entry.firstIdx; i++) out.push(messages[i]);
|
|
266
|
+
out.push(summaryMessageOf(entry.block));
|
|
267
|
+
foldedMessages += entry.lastIdx - entry.firstIdx + 1;
|
|
268
|
+
at = entry.lastIdx + 1;
|
|
269
|
+
applied += 1;
|
|
270
|
+
}
|
|
271
|
+
for (let i = at; i < messages.length; i++) out.push(messages[i]);
|
|
272
|
+
return { messages: out, applied, foldedMessages, unlocated };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Epoch-boundary detection: every block failed to LOCATE inside the current
|
|
276
|
+
* re-send — the host replaced history (its own compaction) and the ledger
|
|
277
|
+
* aged out as a whole. The fail-open skip stays correct, but a growth
|
|
278
|
+
* baseline carried over from the previous epoch does not: the caller
|
|
279
|
+
* re-anchors it exactly once per epoch. */
|
|
280
|
+
export function ledgerIsStale(view, ledger) {
|
|
281
|
+
const blocksCount = Array.isArray(ledger?.blocks) ? ledger.blocks.length : 0;
|
|
282
|
+
return blocksCount > 0 && view.applied === 0 && view.unlocated === blocksCount;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Install the built view for one dispatch. Host immutability varies: probe
|
|
286
|
+
* and fresh re-sends hand out mutable arrays (splice in place — cheapest,
|
|
287
|
+
* identity-preserving), resumed sessions re-send FROZEN host-assembled
|
|
288
|
+
* history (splice throws on its internal index deletes), and some dispatch
|
|
289
|
+
* options are frozen objects on top of that. Returns the strategy used so
|
|
290
|
+
* the caller can log it: "in-place" | "swapped" | "none". */
|
|
291
|
+
export function injectView(options, messages, view) {
|
|
292
|
+
if (view.applied > 0) {
|
|
293
|
+
try {
|
|
294
|
+
replaceInPlace(messages, view.messages);
|
|
295
|
+
return "in-place";
|
|
296
|
+
} catch {
|
|
297
|
+
/* frozen array — fall through to the reference swap */
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
options.messages = view.messages;
|
|
301
|
+
return "swapped";
|
|
302
|
+
} catch {
|
|
303
|
+
/* frozen options — this dispatch cannot carry the view; fail open */
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return "none";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Replace an array's CONTENT in place (identity preserved — the llm/stream
|
|
310
|
+
* contract mutates the live request array). Chunked pushes keep huge
|
|
311
|
+
* histories clear of the argument-count limit. */
|
|
312
|
+
export function replaceInPlace(target, source) {
|
|
313
|
+
target.splice(0, target.length);
|
|
314
|
+
for (let i = 0; i < source.length; i += 2048) target.push(...source.slice(i, i + 2048));
|
|
315
|
+
return target;
|
|
316
|
+
}
|
package/host/context-watch.js
CHANGED
|
@@ -53,6 +53,8 @@ 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 { recentTrimViewTokensOf } from "./context-trim/index.js";
|
|
57
|
+
import { recordForkLink, trimStateDirOf } from "./context-trim/fork.js";
|
|
56
58
|
|
|
57
59
|
/** Tools whose result text is accounted against the per-turn read budget. */
|
|
58
60
|
const ACCOUNT_TOOLS = new Set(["read", "glob", "grep", "bash"]);
|
|
@@ -146,11 +148,23 @@ function readThresholds(config) {
|
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
/** Measure one agent's session now; null when the meter is missing/failing. */
|
|
149
|
-
function measureNow(ctx, agent) {
|
|
151
|
+
export function measureNow(ctx, agent) {
|
|
150
152
|
try {
|
|
151
153
|
const measurement = agent?.session ? ctx.tokenMeter?.measure?.(agent.session) : undefined;
|
|
152
154
|
const used = Number.isFinite(measurement?.totalTokens) ? measurement.totalTokens : null;
|
|
153
|
-
|
|
155
|
+
// Meter duality (design §8): prefer the trim layer's fresh outbound-view
|
|
156
|
+
// estimate when present — billion-context can fold far more away than the
|
|
157
|
+
// meter's stored tally admits, and the watermark must track what the
|
|
158
|
+
// model actually sees. Strictly additive + fail-open: any trim failure
|
|
159
|
+
// falls back to the stored measure.
|
|
160
|
+
try {
|
|
161
|
+
const sessionId = agent?.session?.id ?? (typeof agent?.id === "string" ? agent.id : null);
|
|
162
|
+
const viewTokens = sessionId === null ? null : recentTrimViewTokensOf(sessionId);
|
|
163
|
+
if (viewTokens !== null) return viewTokens;
|
|
164
|
+
} catch {
|
|
165
|
+
// fall through to the stored measure
|
|
166
|
+
}
|
|
167
|
+
return used;
|
|
154
168
|
} catch {
|
|
155
169
|
return null;
|
|
156
170
|
}
|
|
@@ -443,6 +457,12 @@ function applyContextWatch(ctx, config) {
|
|
|
443
457
|
);
|
|
444
458
|
return { childId: null, reason };
|
|
445
459
|
}
|
|
460
|
+
// Copy-on-fork: the backup child inherits this session's trim fold
|
|
461
|
+
// ledger at its first model call (host/context-trim/fork.js). The
|
|
462
|
+
// link is best-effort bookkeeping on an already-successful fork —
|
|
463
|
+
// recordForkLink never throws; a lost link only downgrades the
|
|
464
|
+
// child to a clean ledger.
|
|
465
|
+
recordForkLink(sessionId, started.childId, { stateDir: trimStateDirOf(config), warn });
|
|
446
466
|
return { childId: started.childId, reason: null };
|
|
447
467
|
} catch (error) {
|
|
448
468
|
const reason = error?.message ?? String(error);
|
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);
|
package/host/subagent-model.js
CHANGED
package/index.js
CHANGED
|
@@ -69,6 +69,9 @@ import { applySubagentModel } from "./host/subagent-model.js";
|
|
|
69
69
|
|
|
70
70
|
// Context-watermark layer (banner + read budget + handover + /ctx-* commands).
|
|
71
71
|
import { applyContextWatch } from "./host/context-watch.js";
|
|
72
|
+
// Context-trim layer (deterministic outbound-view folding + idle-boundary
|
|
73
|
+
// automatic folds + ctx_trim_fold/ctx_trim_status advisory tools).
|
|
74
|
+
import { applyContextTrim } from "./host/context-trim/index.js";
|
|
72
75
|
// Vision dispatch gate (/vision command + vision-state route for the
|
|
73
76
|
// composer-dock hint): unlocks image sending on text-only models.
|
|
74
77
|
import { applyVision } from "./host/vision.js";
|
|
@@ -100,7 +103,17 @@ const name = "dsh-switchman";
|
|
|
100
103
|
* only inside each preset's per-agent isolated group (isolate: { compaction:
|
|
101
104
|
* true }), never at this root — host/context-watch.js reaches it by borrowing
|
|
102
105
|
* the session's own per-agent /compact handler through
|
|
103
|
-
* ctx.commands.find(agent, "compact").
|
|
106
|
+
* ctx.commands.find(agent, "compact").
|
|
107
|
+
*
|
|
108
|
+
* The context-trim layer (host/context-trim/) adds fully-automatic context
|
|
109
|
+
* compression: an llm/stream listener rebuilds a deterministic outbound
|
|
110
|
+
* view (folded ranges replaced by ONE synthetic summary message each,
|
|
111
|
+
* per-session ledger) in place before every agent-loop model call, an
|
|
112
|
+
* agent/status idle hook runs the fold decisions (growth gate / pressure /
|
|
113
|
+
* emergency tiers; side-model summaries with mechanical emergency
|
|
114
|
+
* fallback), and the ctx_trim_fold / ctx_trim_status tools expose the
|
|
115
|
+
* advisory surface. Fail-open throughout: the listener always calls
|
|
116
|
+
* next(), and trimming degrades to a no-op on any internal failure. */
|
|
104
117
|
const inject = ["skills", "systemPrompt", "settings", "tokenMeter", "commands", "tools", "agents", "webServer", "subagents", "sessions", "pluginManager", "sessionQuery"];
|
|
105
118
|
|
|
106
119
|
/** Must equal `BUNDLED_SKILL_RANK` of @deepseek-ai/dsh-skill (packaged skill
|
|
@@ -193,6 +206,7 @@ function apply(ctx, config) {
|
|
|
193
206
|
applyDispatch(ctx, config);
|
|
194
207
|
applyTeams(ctx, config);
|
|
195
208
|
applyContextWatch(ctx, config);
|
|
209
|
+
applyContextTrim(ctx, config);
|
|
196
210
|
applyVision(ctx, config);
|
|
197
211
|
applyIntent(ctx, config);
|
|
198
212
|
applySubagentModel(ctx);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-switchman",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
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": [
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
},
|
|
23
23
|
"type": "module",
|
|
24
24
|
"main": "./index.js",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test scripts/test/*.test.mjs"
|
|
27
|
+
},
|
|
25
28
|
"icon": "./icon.svg",
|
|
26
29
|
"engines": {
|
|
27
30
|
"node": ">=24",
|