dsh-switchman 1.3.0 → 1.5.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 +111 -0
- package/README.es.md +111 -0
- package/README.fr.md +111 -0
- package/README.it.md +111 -0
- package/README.ja.md +111 -0
- package/README.ko.md +111 -0
- package/README.md +58 -26
- package/README.pt.md +111 -0
- package/README.ru.md +111 -0
- package/README.zh-TW.md +111 -0
- package/README.zh.md +55 -23
- package/client.js +900 -17
- package/cordis.patch.yml +24 -8
- package/host/config.js +23 -0
- package/host/context-watch.js +267 -68
- package/host/dispatch.js +107 -6
- package/host/handover-state.js +38 -0
- package/host/lang.js +211 -36
- package/host/lib/dsh-home.js +18 -0
- package/host/routes.js +138 -7
- package/host/subagent-model.js +147 -0
- package/host/subagents.js +144 -0
- package/host/teams.js +430 -0
- package/host/ui-locale.js +35 -0
- package/host/vision.js +367 -0
- package/index.js +40 -8
- package/package.json +4 -1
package/cordis.patch.yml
CHANGED
|
@@ -3,12 +3,20 @@
|
|
|
3
3
|
#
|
|
4
4
|
# One bundle, four preset overrides: each of the four shipped Web presets
|
|
5
5
|
# (standard / ptc / minimal / cordis) keeps its exact shipped plugin list and
|
|
6
|
-
# gains, in its persona suffix, the dsh-switchman
|
|
7
|
-
#
|
|
6
|
+
# gains, in its persona suffix, the dsh-switchman base dispatch doctrine
|
|
7
|
+
# (delegation discipline, orchestration/token rules, tiered verification).
|
|
8
|
+
# The persona suffix renders as the LAST system-prompt section
|
|
8
9
|
# (DEPLOYMENT_PERSONA_SUFFIX, order 10200), after the conservative TEAM_POLICY
|
|
9
10
|
# section (order 600) contributed by the Host-level
|
|
10
11
|
# @deepseek-ai/dsh-experimental-tool-agent-team, so this doctrine is the
|
|
11
|
-
# operative
|
|
12
|
+
# operative discipline for sessions of every preset.
|
|
13
|
+
#
|
|
14
|
+
# The Agent Teams clauses (conservative-policy reversal, spawn_teammate
|
|
15
|
+
# trigger conditions, shared task-board discipline) are NOT static: the Host
|
|
16
|
+
# half (host/teams.js) carries them in a dynamic `[SWITCHMAN:TEAMS]` section
|
|
17
|
+
# (order 10250, between this suffix and the language section) that is only
|
|
18
|
+
# injected while the teamsMode setting is ON. OFF leaves the factory
|
|
19
|
+
# conservative team policy in charge; nothing here changes.
|
|
12
20
|
#
|
|
13
21
|
# The Agent Teams tools themselves (spawn_teammate, team_task_*, send_message,
|
|
14
22
|
# wait_agent, …) come from the Host composition, i.e. the shipped
|
|
@@ -25,6 +33,17 @@
|
|
|
25
33
|
# the shipped presets, re-sync these lists from the new preset files, then
|
|
26
34
|
# reinstall this bundle.
|
|
27
35
|
#
|
|
36
|
+
# NEVER set `modelSelectionSettings: true` on the tool-subagent-fork row (any
|
|
37
|
+
# preset). Only the spawn row may carry it: while the subagent model-selection
|
|
38
|
+
# whitelist is enabled, every `@deepseek-ai/dsh-tool-subagent` instance with
|
|
39
|
+
# modelSelectionSettings registers `list_subagent_models` into each new Agent's
|
|
40
|
+
# scope, and a second instance makes session composition throw
|
|
41
|
+
# `tool "list_subagent_models" is already registered in this scope` — breaking
|
|
42
|
+
# creation of ALL new sessions and the recomposition of old ones. The shipped
|
|
43
|
+
# presets (verified against installed dsh-web-app 0.2.0-rc.2 and the fork's
|
|
44
|
+
# 0.2.1-alpha.1) enable it on the spawn row only; fork-side explicit routing
|
|
45
|
+
# is not a factory capability.
|
|
46
|
+
#
|
|
28
47
|
# Known consequences of overriding:
|
|
29
48
|
# - plugin_manager `set_plugin` on these preset rows reports `overridden`.
|
|
30
49
|
# - An edit saved from the Web preset editor lands in the profile patch layer,
|
|
@@ -52,14 +71,11 @@
|
|
|
52
71
|
|
|
53
72
|
总纲:在准确理解需求、严谨思考、最优质量、最低返工率的前提下最小化总 token 消耗。返工是最贵的 token——过程与铺陈可以省,验证与关键推理绝不省。
|
|
54
73
|
|
|
55
|
-
系统提示词前文的默认团队策略写的是「仅当用户明确要求时才创建 teammate」。该默认策略在当前 profile 下不适用:用户安装 dsh-switchman 即预先授权,你应当自主判断、自主触发,无需再询问用户。
|
|
56
|
-
|
|
57
74
|
## 一、默认委派,例外亲做(delegate by default)
|
|
58
75
|
- 委派只消耗一个全新子上下文、只收回一份摘要;亲自读写每一份都会膨胀本会话上下文,长期更贵。长或重的上下文偏向委派,琐碎小事留在手里。
|
|
59
76
|
- 仅当满足全部条件才亲自做:认知负载低或中、读取合计 <200 行、改动 <50 行;强迭代小步试错(每步都要立刻用上一步结果);期望收益 <3k token 的收尾。
|
|
60
77
|
- 位置不确定的检索、跨多文件调查、大范围重构、独立研究或方案对比:一律委派,侦察优先派 economy 泳道。
|
|
61
78
|
- 任务开始时判断一次,中途规模或结构变化时重做;结论不必请示用户。
|
|
62
|
-
- 需要多成员持久协作、共享任务板、消息往来的用 Agent Teams(满足任一即触发 spawn_teammate:可并行的多个独立子任务;量大且自包含;主上下文水位已高;需要角色分离);一次性、无需共享状态的单点调查用 subagent。
|
|
63
79
|
|
|
64
80
|
## 二、编排与 token 纪律(Lead 职责)
|
|
65
81
|
- prompt 必须自包含:目标、已知事实与结论、文件路径、期望输出与验收标准;已查明的事实不让对方重查;固定段在前、可变段在后。
|
|
@@ -68,11 +84,11 @@
|
|
|
68
84
|
- 禁止复活死会话:子智能体达 cap 终止后,用自包含 prompt 新派一个,不 resume 旧壳。
|
|
69
85
|
- 广搜先问:首次整项目范围的搜索先向用户澄清范围,定点检索不受此限。
|
|
70
86
|
- 大额派发先估成本:预计 ≥3 个成员或总消耗 >100k token 时,先报规模与预估请用户确认。
|
|
71
|
-
-
|
|
87
|
+
- 子路由两通道:显式传 provider/model/effort 任一字段会经 DSH 会话级授权白名单校验(设置页「允许 Agent 为子智能体选择模型」勾选的授权列表,与泳道池无关);被拒时用 list_subagent_models 查实际可用路由再派,或改为完全不传三字段走隐式继承(不受校验,子智能体用默认值或继承主会话路由)。泳道池只是偏好排序,授权边界以 DSH 设置为准,不试图改写它。
|
|
72
88
|
|
|
73
89
|
## 三、分级验证(质量下限,不可省)
|
|
74
90
|
- 改动 >20 行或涉及多个调用点:交 tester 验证。
|
|
75
|
-
- 改动 >300 行,或触及核心/安全/数据一致性逻辑:交 reviewer
|
|
91
|
+
- 改动 >300 行,或触及核心/安全/数据一致性逻辑:交 reviewer 独立复审。复审独立性以「代码作者」为锚而非主会话:作者模型 = 写出该改动的智能体的模型(子智能体按派发时显式路由记,未显式指定按继承记为主会话模型)。优先选与作者模型不同的复审模型;能同时避开主会话模型更佳(其集成复核视角已覆盖)。池内确实避不开作者模型时允许同模型复审,在结论中声明 DOWNGRADED 并注明相对作者还是相对主会话降级。
|
|
76
92
|
- 任何降到更弱模型或更省验证的决定都必须显式声明;沉默放弃验证禁止。
|
|
77
93
|
|
|
78
94
|
## 四、边界
|
package/host/config.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* - lang* project language preference (Phase 1)
|
|
12
12
|
* - pool/rank dispatch pools and capability ranking (Phase 2)
|
|
13
13
|
* - wm* context watermark control (Phase 3)
|
|
14
|
+
* - teams* Agent Teams doctrine mode + whitelist sync (teams mode)
|
|
14
15
|
*
|
|
15
16
|
* Ported from opencode-switchman's opencode-switchman.jsonc surface
|
|
16
17
|
* (oc/src/config.ts:23-51, oc/src/types.ts:281-344) onto DSH settings.
|
|
@@ -46,6 +47,10 @@ export const EffortEntrySchema = z.object({
|
|
|
46
47
|
/** All dsh-switchman settings. Field names are the settings mutation paths. */
|
|
47
48
|
export const Config = z.object({
|
|
48
49
|
// --- Phase 1: project language preference -----------------------------
|
|
50
|
+
/** Where the three language slots live: "global" = these profile-level
|
|
51
|
+
* fields below; "project" = each project's .switchman/lang.json (per
|
|
52
|
+
* session cwd; missing file = ask once per session and save there). */
|
|
53
|
+
langScope: z.union(["global", "project"]).default("global").volatile(),
|
|
49
54
|
/** Language for conversational replies. Empty = ask once, then remember. */
|
|
50
55
|
langConversation: z.string().default("").volatile(),
|
|
51
56
|
/** Language for code comments. Empty = follow langConversation. */
|
|
@@ -101,6 +106,24 @@ export const Config = z.object({
|
|
|
101
106
|
wmSubagentCap: z.boolean().default(true).volatile(),
|
|
102
107
|
/** Subagent hard cap; 0 = share wmForceTokens. */
|
|
103
108
|
wmSubagentForceTokens: z.number().min(0).default(0).volatile(),
|
|
109
|
+
|
|
110
|
+
// --- Teams mode: doctrine injection + whitelist sync -------------------
|
|
111
|
+
/** Agent Teams master switch. ON = inject the `[SWITCHMAN:TEAMS]` doctrine
|
|
112
|
+
* section (order 10250), keep the DSH child-model whitelist detection in
|
|
113
|
+
* the pools table, and asynchronously ensure the agent-team-profile
|
|
114
|
+
* bundle is enabled. OFF = plain subagent dispatch mode: no teams
|
|
115
|
+
* doctrine, no whitelist detection (the DSH factory conservative team
|
|
116
|
+
* policy takes over); the bundle is never disabled and the whitelist is
|
|
117
|
+
* never cleared. */
|
|
118
|
+
teamsMode: z.boolean().default(false).volatile(),
|
|
119
|
+
/** Replace-sync the six-pool union into DSH's subagent model-selection
|
|
120
|
+
* whitelist (`subagent-model-selection-settings`) whenever the teams
|
|
121
|
+
* orchestration runs — an INDEPENDENT switch: it does not require
|
|
122
|
+
* teamsMode, and teamsMode alone does not sync. switchman is the single
|
|
123
|
+
* source of truth: the deduplicated union overwrites allowedModels
|
|
124
|
+
* wholesale. The whitelist only applies to top-level sessions composed
|
|
125
|
+
* afterwards. */
|
|
126
|
+
syncWhitelist: z.boolean().default(false).volatile(),
|
|
104
127
|
});
|
|
105
128
|
|
|
106
129
|
/** The six dispatch lanes, in display order. */
|
package/host/context-watch.js
CHANGED
|
@@ -18,14 +18,19 @@
|
|
|
18
18
|
* compaction only inside each preset's isolated per-agent group
|
|
19
19
|
* (isolate: { compaction: true }), so no root-level inject or lookup can
|
|
20
20
|
* reach it — the borrowed handler's closure carries the right composition.
|
|
21
|
-
*
|
|
22
|
-
* plus a 10-minute cooldown
|
|
23
|
-
* flow: fork a dormant backup
|
|
24
|
-
* pool model), write the
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
21
|
+
* Every handover trigger — the force-tier automation (fire-and-forget,
|
|
22
|
+
* session-level in-flight flag plus a 10-minute cooldown) and manual
|
|
23
|
+
* /ctx-handover — shares one four-step flow: fork a dormant backup
|
|
24
|
+
* session (subagents' fork provider, economy pool model), write the
|
|
25
|
+
* handover document skeleton, compact via the borrowed per-agent
|
|
26
|
+
* /compact (retried on a growing backoff until the agent goes idle,
|
|
27
|
+
* because the automation fires mid-turn), then wake this session
|
|
28
|
+
* (agent.followup + sessions.flush, the schedule service's delivery
|
|
29
|
+
* channel) to fill in the document and continue the unfinished task.
|
|
30
|
+
* The live handover phase feeds the force-tier banner so progress is
|
|
31
|
+
* visible instead of silent. /ctx-pause and /ctx-resume toggle
|
|
32
|
+
* process-lifetime pause state (a restart resumes enforcement — the
|
|
33
|
+
* safe side).
|
|
29
34
|
*
|
|
30
35
|
* All session state is in-process Maps; every path is fail-open (a failing
|
|
31
36
|
* measurement or missing service degrades the banner to "" or the gate to
|
|
@@ -35,6 +40,8 @@
|
|
|
35
40
|
import { randomUUID } from "node:crypto";
|
|
36
41
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
37
42
|
import { join } from "node:path";
|
|
43
|
+
import { clearHandover, handoverOf, recordHandover } from "./handover-state.js";
|
|
44
|
+
import { enumerateRunningSubagents, sessionRecordPath, sessionRecordsDir } from "./subagents.js";
|
|
38
45
|
|
|
39
46
|
/** Tools whose result text is accounted against the per-turn read budget. */
|
|
40
47
|
const ACCOUNT_TOOLS = new Set(["read", "glob", "grep", "bash"]);
|
|
@@ -140,8 +147,9 @@ function measureNow(ctx, agent) {
|
|
|
140
147
|
|
|
141
148
|
// ---- banner rendering (pure given state + measurement) ---------------------
|
|
142
149
|
|
|
143
|
-
/** Root-session banner: byte-stable anchor line + tier advice lines
|
|
144
|
-
|
|
150
|
+
/** Root-session banner: byte-stable anchor line + tier advice lines plus
|
|
151
|
+
* the live handover progress (phase/attempt) and auto-cooldown state. */
|
|
152
|
+
function renderRootBanner(level, used, th, status) {
|
|
145
153
|
const head =
|
|
146
154
|
`[SWITCHMAN:WATERMARK] level=${level} used=${kilo(used)}` +
|
|
147
155
|
` budget=${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
|
|
@@ -153,11 +161,22 @@ function renderRootBanner(level, used, th, autoHandover) {
|
|
|
153
161
|
if (level === "hard") lines.push(`hard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.`);
|
|
154
162
|
if (level === "force") {
|
|
155
163
|
lines.push(`force tier: MANDATORY wrap-up now — produce the final answer from what you already hold.`);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
164
|
+
const phase = status?.phase;
|
|
165
|
+
if (phase === "backup") {
|
|
166
|
+
lines.push(`context handover running: forking the backup session and writing the handover document…`);
|
|
167
|
+
} else if (phase === "compacting") {
|
|
168
|
+
lines.push(`context handover running: compaction attempt ${status?.attempt ?? "?"} (retries while the turn is still running)…`);
|
|
169
|
+
} else if (phase === "continuation") {
|
|
170
|
+
lines.push(`context handover completing: waking this session to fill in the document and continue the task…`);
|
|
171
|
+
} else if (status?.autoHandover) {
|
|
172
|
+
lines.push(
|
|
173
|
+
status?.cooldownMin > 0
|
|
174
|
+
? `automatic handover cooldown: ${status.cooldownMin} min left; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
|
|
175
|
+
: `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" : ""}.`,
|
|
176
|
+
);
|
|
177
|
+
} else {
|
|
178
|
+
lines.push(`run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to trigger a manual context handover.`);
|
|
179
|
+
}
|
|
161
180
|
}
|
|
162
181
|
return lines.join("\n");
|
|
163
182
|
}
|
|
@@ -199,7 +218,15 @@ function renderBanner(ctx, config, state, agent) {
|
|
|
199
218
|
return renderSubBanner(used, subTh);
|
|
200
219
|
}
|
|
201
220
|
const level = state.paused.has(sessionId) ? "paused" : levelOf(used, th);
|
|
202
|
-
|
|
221
|
+
const live = handoverOf(sessionId);
|
|
222
|
+
const elapsed = Date.now() - (state.lastHandover.get(sessionId) ?? 0);
|
|
223
|
+
return renderRootBanner(level, used, th, {
|
|
224
|
+
autoHandover: readBool(config.wmAutoHandover, true),
|
|
225
|
+
phase: live?.phase,
|
|
226
|
+
attempt: live?.attempt,
|
|
227
|
+
cooldownMin: Math.max(0, Math.ceil((HANDOVER_COOLDOWN_MS - elapsed) / 60_000)),
|
|
228
|
+
tool: state.handoverTool ?? undefined,
|
|
229
|
+
});
|
|
203
230
|
} catch {
|
|
204
231
|
return "";
|
|
205
232
|
}
|
|
@@ -260,6 +287,7 @@ function applyContextWatch(ctx, config) {
|
|
|
260
287
|
const state = {
|
|
261
288
|
paused: new Set(),
|
|
262
289
|
inflight: new Set(),
|
|
290
|
+
handoverTool: null,
|
|
263
291
|
lastHandover: new Map(),
|
|
264
292
|
readUsed: new Map(),
|
|
265
293
|
};
|
|
@@ -282,46 +310,32 @@ function applyContextWatch(ctx, config) {
|
|
|
282
310
|
// ctx.commands.find(agent, "compact") merges that agent's scoped layer and
|
|
283
311
|
// returns the definition whose handler closure already carries the right
|
|
284
312
|
// composition context with compaction injected.
|
|
285
|
-
|
|
286
|
-
|
|
313
|
+
/** Find the agent's scoped /compact definition without invoking it. */
|
|
314
|
+
const compactDefinition = (agent) => {
|
|
287
315
|
try {
|
|
288
|
-
definition = ctx.commands?.find?.(agent, "compact");
|
|
316
|
+
const definition = ctx.commands?.find?.(agent, "compact");
|
|
317
|
+
return typeof definition?.handler === "function" ? definition : undefined;
|
|
289
318
|
} catch {
|
|
290
|
-
|
|
319
|
+
return undefined;
|
|
291
320
|
}
|
|
292
|
-
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const sessionCompact = (agent, commandId, signal) => {
|
|
324
|
+
const definition = compactDefinition(agent);
|
|
325
|
+
if (definition === undefined) return undefined;
|
|
293
326
|
const invocation = Object.freeze({
|
|
294
327
|
commandId,
|
|
295
328
|
agent,
|
|
296
329
|
rawInput: "",
|
|
297
330
|
attachments: Object.freeze([]),
|
|
298
|
-
|
|
331
|
+
// The borrowed /compact handler (and compactNow behind it) touches
|
|
332
|
+
// signal.throwIfAborted()/aborted unconditionally; the auto path
|
|
333
|
+
// has no real signal, so always carry a fresh un-aborted one.
|
|
334
|
+
signal: signal ?? new AbortController().signal,
|
|
299
335
|
});
|
|
300
336
|
return Promise.resolve(definition.handler(invocation));
|
|
301
337
|
};
|
|
302
338
|
|
|
303
|
-
const startHandover = (agent, sessionId) => {
|
|
304
|
-
state.inflight.add(sessionId);
|
|
305
|
-
let promise;
|
|
306
|
-
try {
|
|
307
|
-
promise = sessionCompact(agent, `switchman-auto-${sessionId}`, undefined);
|
|
308
|
-
if (promise === undefined)
|
|
309
|
-
warn(`auto handover found no per-agent /compact command for session ${sessionId} — nothing to do`);
|
|
310
|
-
} catch (error) {
|
|
311
|
-
warn(`handover failed to start: ${error?.message ?? error}`);
|
|
312
|
-
promise = undefined;
|
|
313
|
-
}
|
|
314
|
-
const settle = () => state.inflight.delete(sessionId);
|
|
315
|
-
if (promise !== undefined && typeof promise.then === "function") {
|
|
316
|
-
promise.then(
|
|
317
|
-
() => ctx.logger?.info?.(`dsh-switchman: context handover settled for session ${sessionId}`),
|
|
318
|
-
(error) => warn(`context handover failed for session ${sessionId}: ${error?.message ?? error}`),
|
|
319
|
-
).then(settle, settle);
|
|
320
|
-
} else {
|
|
321
|
-
settle();
|
|
322
|
-
}
|
|
323
|
-
};
|
|
324
|
-
|
|
325
339
|
/** First {provider, model} route of a volatile pool field, or null. */
|
|
326
340
|
const firstPoolRoute = (field) => {
|
|
327
341
|
const raw = readValue(field);
|
|
@@ -378,14 +392,30 @@ function applyContextWatch(ctx, config) {
|
|
|
378
392
|
* the session's working directory. The mechanical facts land now (before
|
|
379
393
|
* compaction); the agent's continuation turn fills in the task-state
|
|
380
394
|
* summary using the compaction summary it can still see. Null when the
|
|
381
|
-
* session exposes no usable cwd or the write fails.
|
|
382
|
-
|
|
395
|
+
* session exposes no usable cwd or the write fails. Any still-running
|
|
396
|
+
* background subagents are recorded too: compaction orphans them from
|
|
397
|
+
* every plugin-visible registry, so without this section the compacted
|
|
398
|
+
* agent cannot know its dispatched work is alive (and double-dispatches).
|
|
399
|
+
* */
|
|
400
|
+
const writeHandoverSkeleton = (agent, sessionId, backupId, used, running) => {
|
|
383
401
|
const cwd = agent?.session?.header?.cwd;
|
|
384
402
|
if (typeof cwd !== "string" || cwd === "") return null;
|
|
385
403
|
const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
|
|
386
404
|
const directory = join(cwd, ".dsh-switchman", "handover");
|
|
387
405
|
const short = sessionId.replace(/^session-/u, "").slice(0, 8) || sessionId;
|
|
388
406
|
const path = join(directory, `${stamp}-${short}.md`);
|
|
407
|
+
const runningSection =
|
|
408
|
+
Array.isArray(running) && running.length > 0
|
|
409
|
+
? [
|
|
410
|
+
`## Running background subagents (captured before compaction — still alive)`,
|
|
411
|
+
``,
|
|
412
|
+
...running.map(
|
|
413
|
+
(child) =>
|
|
414
|
+
`- \`${child.id}\`${child.label ? ` — ${child.label}` : ""}${child.mode ? ` (${child.mode})` : ""} — STILL RUNNING. Collect its report when it settles (in-session completion notice, or read its session record at ${sessionRecordPath(child.id)}) before re-dispatching anything similar — do not duplicate live work.`,
|
|
415
|
+
),
|
|
416
|
+
``,
|
|
417
|
+
]
|
|
418
|
+
: [];
|
|
389
419
|
try {
|
|
390
420
|
mkdirSync(directory, { recursive: true });
|
|
391
421
|
writeFileSync(
|
|
@@ -397,6 +427,7 @@ function applyContextWatch(ctx, config) {
|
|
|
397
427
|
`- context used before compaction: ~${used ?? "unknown"} tokens`,
|
|
398
428
|
`- backup fork session: ${backupId ?? "(fork failed — the full history remains in this session's append-only log)"}`,
|
|
399
429
|
``,
|
|
430
|
+
...runningSection,
|
|
400
431
|
`## Task state (filled in by the agent right after the handover)`,
|
|
401
432
|
``,
|
|
402
433
|
`- goal / objective in flight:`,
|
|
@@ -420,11 +451,23 @@ function applyContextWatch(ctx, config) {
|
|
|
420
451
|
* uses: agent.followup + sessions.flush) instructing the agent to fill
|
|
421
452
|
* in the handover document from the compaction summary now in context
|
|
422
453
|
* and continue the unfinished task. */
|
|
423
|
-
const armContinuation = async (agent, backupId, docPath) => {
|
|
454
|
+
const armContinuation = async (agent, backupId, docPath, running) => {
|
|
424
455
|
if (typeof agent?.followup !== "function") {
|
|
425
456
|
warn("handover continuation skipped: agent exposes no followup()");
|
|
426
457
|
return;
|
|
427
458
|
}
|
|
459
|
+
const runningCount = Array.isArray(running) ? running.length : 0;
|
|
460
|
+
const runningSentence =
|
|
461
|
+
runningCount === 0
|
|
462
|
+
? `No background subagents were detected running at handover time.`
|
|
463
|
+
: docPath !== null
|
|
464
|
+
? `${runningCount} background subagent(s) dispatched earlier are STILL RUNNING and are listed in the handover document — collect their results (in-session completion notices, or their session records) instead of re-dispatching duplicate work.`
|
|
465
|
+
: `${runningCount} background subagent(s) dispatched earlier are STILL RUNNING (the handover document could not be written; inline list): ${running
|
|
466
|
+
.map(
|
|
467
|
+
(child) =>
|
|
468
|
+
`${child.id}${child.label ? ` (${child.label})` : ""} → record ${sessionRecordPath(child.id)}`,
|
|
469
|
+
)
|
|
470
|
+
.join("; ")} — collect their results instead of re-dispatching duplicate work (records live under ${sessionRecordsDir()}/<childId>.json).`;
|
|
428
471
|
const instruction = [
|
|
429
472
|
"[SWITCHMAN:HANDOVER] A context handover just completed for this session.",
|
|
430
473
|
backupId !== null
|
|
@@ -433,6 +476,7 @@ function applyContextWatch(ctx, config) {
|
|
|
433
476
|
docPath !== null
|
|
434
477
|
? `The handover document skeleton is at ${docPath}.`
|
|
435
478
|
: `Create the handover document under .dsh-switchman/handover/ in the working directory.`,
|
|
479
|
+
runningSentence,
|
|
436
480
|
"Using the compaction summary now in your context, fill in the handover document (goal, completed, in progress, next steps, files), then continue the unfinished task — do not wait for further instructions.",
|
|
437
481
|
].join(" ");
|
|
438
482
|
try {
|
|
@@ -448,39 +492,134 @@ function applyContextWatch(ctx, config) {
|
|
|
448
492
|
}
|
|
449
493
|
};
|
|
450
494
|
|
|
451
|
-
/**
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
|
|
495
|
+
/** The shared four-step handover (see the header comment), used by
|
|
496
|
+
* manual /ctx-handover and the force-tier automation alike. The
|
|
497
|
+
* compaction step retries on a growing backoff: both triggers usually
|
|
498
|
+
* fire mid-turn, the runMaintenance behind the borrowed /compact only
|
|
499
|
+
* accepts an IDLE agent, and "busy" arrives as a RESOLVED
|
|
500
|
+
* {kind:"error"} (not a rejection) — so both branches funnel into
|
|
501
|
+
* retryOrFail. Once the agent goes idle the compact lands before the
|
|
502
|
+
* next turn's driver (maintenance latches wake requests by design).
|
|
503
|
+
* The per-session phase map feeds the force-tier banner so the wait is
|
|
504
|
+
* visible instead of silent. */
|
|
505
|
+
const runHandover = async (agent, sessionId, commandId, signal, source = "manual") => {
|
|
460
506
|
if (sessionId === null || agent?.session === undefined)
|
|
461
507
|
return { kind: "error", text: "/ctx-handover: no active session" };
|
|
462
508
|
if (state.inflight.has(sessionId))
|
|
463
509
|
return { kind: "success", text: "[SWITCHMAN:WATERMARK] a handover is already in progress for this session." };
|
|
464
|
-
|
|
465
|
-
if (borrowed === undefined)
|
|
510
|
+
if (compactDefinition(agent) === undefined)
|
|
466
511
|
return {
|
|
467
512
|
kind: "error",
|
|
468
513
|
text: "/ctx-handover: this session's preset exposes no /compact command, so no compaction backend is reachable.",
|
|
469
514
|
};
|
|
470
515
|
state.inflight.add(sessionId);
|
|
471
516
|
try {
|
|
517
|
+
recordHandover(sessionId, { phase: "backup", source, at: Date.now() });
|
|
472
518
|
const backup = await forkBackup(agent, sessionId, signal);
|
|
473
519
|
const backupId = backup.childId;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
520
|
+
// Snapshot the live background children BEFORE compaction — a
|
|
521
|
+
// dispatch racing the handover would otherwise be missed, and the
|
|
522
|
+
// same snapshot feeds both the document section and the
|
|
523
|
+
// continuation instruction. The sanctioned services answer first
|
|
524
|
+
// (live registry, no flush lag); the projcache disk scan below is
|
|
525
|
+
// the fail-open fallback.
|
|
526
|
+
const runningSubagents = await enumerateRunningSubagents(ctx, sessionId);
|
|
527
|
+
const docPath = writeHandoverSkeleton(agent, sessionId, backupId, measureNow(ctx, agent), runningSubagents);
|
|
528
|
+
const delays = [5_000, 15_000, 30_000, 60_000, 120_000, 300_000];
|
|
529
|
+
const outcome = await new Promise((resolve) => {
|
|
530
|
+
let attempts = 0;
|
|
531
|
+
let attempting = false;
|
|
532
|
+
let timer = null;
|
|
533
|
+
let watchdog = null;
|
|
534
|
+
let finished = false;
|
|
535
|
+
let offStatus = null;
|
|
536
|
+
const finish = (ok, reason) => {
|
|
537
|
+
if (finished) return;
|
|
538
|
+
finished = true;
|
|
539
|
+
if (timer !== null) clearTimeout(timer);
|
|
540
|
+
if (watchdog !== null) clearTimeout(watchdog);
|
|
541
|
+
if (offStatus !== null) {
|
|
542
|
+
try {
|
|
543
|
+
offStatus();
|
|
544
|
+
} catch {
|
|
545
|
+
/* best-effort listener dispose */
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
resolve({ ok, reason });
|
|
549
|
+
};
|
|
550
|
+
const attempt = () => {
|
|
551
|
+
if (finished || attempting) return;
|
|
552
|
+
attempting = true;
|
|
553
|
+
attempts += 1;
|
|
554
|
+
if (timer !== null) {
|
|
555
|
+
clearTimeout(timer);
|
|
556
|
+
timer = null;
|
|
557
|
+
}
|
|
558
|
+
recordHandover(sessionId, { phase: "compacting", source, attempt: attempts, at: Date.now() });
|
|
559
|
+
let promise;
|
|
560
|
+
try {
|
|
561
|
+
promise = sessionCompact(agent, commandId ?? `switchman-${source}-${sessionId}`, signal);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
attempting = false;
|
|
564
|
+
finish(false, `handover failed to start: ${error?.message ?? error}`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (promise === undefined) {
|
|
568
|
+
attempting = false;
|
|
569
|
+
finish(false, "the per-agent /compact command disappeared");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const settleAttempt = (ok, reason) => {
|
|
573
|
+
attempting = false;
|
|
574
|
+
if (ok) {
|
|
575
|
+
finish(true);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (attempts <= delays.length) timer = setTimeout(attempt, delays[attempts - 1]);
|
|
579
|
+
else finish(false, String(reason));
|
|
580
|
+
};
|
|
581
|
+
promise.then(
|
|
582
|
+
(value) => {
|
|
583
|
+
if (value !== null && typeof value === "object" && value.kind === "error") {
|
|
584
|
+
settleAttempt(false, value.text ?? "compact returned an error result");
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
settleAttempt(true);
|
|
588
|
+
},
|
|
589
|
+
(error) => settleAttempt(false, error?.message ?? String(error)),
|
|
590
|
+
);
|
|
591
|
+
};
|
|
592
|
+
// Promptness: the agent/status idle transition (the same event
|
|
593
|
+
// the official compaction layer uses for overflow-retry
|
|
594
|
+
// cleanup) attempts the moment the turn really ends; the
|
|
595
|
+
// timers above stay as the backstop for missed or filtered
|
|
596
|
+
// events. Status reads "idle" externally while our own
|
|
597
|
+
// maintenance runs, and the `attempting` latch collapses
|
|
598
|
+
// those re-entrant events.
|
|
599
|
+
try {
|
|
600
|
+
offStatus = ctx.on("agent/status", (payload) => {
|
|
601
|
+
if (payload?.agent === agent && payload?.status === "idle") attempt();
|
|
602
|
+
});
|
|
603
|
+
} catch {
|
|
604
|
+
/* timer-only fallback */
|
|
605
|
+
}
|
|
606
|
+
// Watchdog: a borrowed /compact that never settles would hold
|
|
607
|
+
// the in-flight latch (and this listener) forever; 20 min is
|
|
608
|
+
// far beyond any real compaction (~2 min observed) yet finite.
|
|
609
|
+
watchdog = setTimeout(
|
|
610
|
+
() => finish(false, "handover watchdog timeout — the borrowed /compact never settled"),
|
|
611
|
+
20 * 60_000,
|
|
612
|
+
);
|
|
613
|
+
attempt();
|
|
614
|
+
});
|
|
615
|
+
if (!outcome.ok)
|
|
477
616
|
return {
|
|
478
617
|
kind: "error",
|
|
479
|
-
text: `/ctx-handover: compaction failed after backup ${backupId ?? "n/a"} — ${
|
|
618
|
+
text: `/ctx-handover: compaction failed after backup ${backupId ?? "n/a"} — ${outcome.reason}`,
|
|
480
619
|
};
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
620
|
+
recordHandover(sessionId, { phase: "continuation", source, at: Date.now() });
|
|
621
|
+
await armContinuation(agent, backupId, docPath, runningSubagents);
|
|
622
|
+
const backupText = backupId ?? `n/a (${backup.reason ?? "log retains full history"})`;
|
|
484
623
|
return {
|
|
485
624
|
kind: "success",
|
|
486
625
|
text: `[SWITCHMAN:WATERMARK] handover complete — backup ${backupText}, document ${docPath ?? "agent-created"}, compaction done, continuation armed.`,
|
|
@@ -488,6 +627,7 @@ function applyContextWatch(ctx, config) {
|
|
|
488
627
|
} catch (error) {
|
|
489
628
|
return { kind: "error", text: `/ctx-handover failed: ${error?.message ?? error}` };
|
|
490
629
|
} finally {
|
|
630
|
+
clearHandover(sessionId);
|
|
491
631
|
state.inflight.delete(sessionId);
|
|
492
632
|
}
|
|
493
633
|
};
|
|
@@ -504,7 +644,13 @@ function applyContextWatch(ctx, config) {
|
|
|
504
644
|
const used = measureNow(ctx, agent);
|
|
505
645
|
if (used === null || levelOf(used, readThresholds(config)) !== "force") return;
|
|
506
646
|
state.lastHandover.set(sessionId, now);
|
|
507
|
-
|
|
647
|
+
runHandover(agent, sessionId, `switchman-auto-${sessionId}`, undefined, "auto").then(
|
|
648
|
+
(result) =>
|
|
649
|
+
ctx.logger?.[result?.kind === "error" ? "warn" : "info"]?.(
|
|
650
|
+
`dsh-switchman: ${result?.text ?? "handover ended"}`,
|
|
651
|
+
),
|
|
652
|
+
(error) => warn(`auto handover crashed: ${error?.message ?? error}`),
|
|
653
|
+
);
|
|
508
654
|
} catch {
|
|
509
655
|
/* fail-open: the banner still reports force */
|
|
510
656
|
}
|
|
@@ -611,10 +757,63 @@ function applyContextWatch(ctx, config) {
|
|
|
611
757
|
});
|
|
612
758
|
ctx.commands.register({
|
|
613
759
|
name: "ctx-handover",
|
|
614
|
-
description: "Trigger a
|
|
760
|
+
description: "Trigger a full context handover for this session now (backup fork + document + compaction + continuation).",
|
|
615
761
|
handler: (invocation) =>
|
|
616
|
-
|
|
762
|
+
runHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, invocation?.signal, "manual"),
|
|
617
763
|
});
|
|
764
|
+
|
|
765
|
+
// --- E. ctx_handover: the agent-callable front door to the same
|
|
766
|
+
// four-step handover. The definition uses the register-level shape —
|
|
767
|
+
// bare ctx.tools.register does not run defineTool's DSL compilation,
|
|
768
|
+
// so parameters is a full object-root JSON Schema — and registers
|
|
769
|
+
// best-effort: a validation rejection only skips the tool, never the
|
|
770
|
+
// plugin. execute returns
|
|
771
|
+
// fast on purpose — awaiting the compaction here would keep the agent
|
|
772
|
+
// busy and starve the idle-only /compact — so the flow runs detached
|
|
773
|
+
// and the result tells the model to end its turn immediately.
|
|
774
|
+
try {
|
|
775
|
+
ctx.tools?.register?.({
|
|
776
|
+
name: "ctx_handover",
|
|
777
|
+
description:
|
|
778
|
+
"Trigger a full dsh-switchman context handover for this session NOW: fork a dormant backup session, write the handover document, compact this session at the idle boundary, then wake it to fill in the document and continue the task. Call it when the watermark banner reaches the force tier or the user asks for a handover, then END the turn immediately.",
|
|
779
|
+
parameters: {
|
|
780
|
+
type: "object",
|
|
781
|
+
properties: {
|
|
782
|
+
reason: {
|
|
783
|
+
type: "string",
|
|
784
|
+
description: "Why the handover is triggered now (e.g. 'force tier watermark reached', 'user request'). Optional.",
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
output: {
|
|
789
|
+
schema: { type: "string" },
|
|
790
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
791
|
+
},
|
|
792
|
+
async execute(_args, exec) {
|
|
793
|
+
const agent = exec?.agent;
|
|
794
|
+
const sessionId = sessionIdOf(agent);
|
|
795
|
+
// No exec.signal here on purpose: the compact must outlive the
|
|
796
|
+
// calling turn, and a turn-scoped AbortSignal could abort it.
|
|
797
|
+
runHandover(agent, sessionId, `switchman-tool-${sessionId ?? "session"}`, undefined, "tool").then(
|
|
798
|
+
(result) =>
|
|
799
|
+
ctx.logger?.[result?.kind === "error" ? "warn" : "info"]?.(
|
|
800
|
+
`dsh-switchman: ${result?.text ?? "handover ended"}`,
|
|
801
|
+
),
|
|
802
|
+
(error) => warn(`ctx_handover handover crashed: ${error?.message ?? error}`),
|
|
803
|
+
);
|
|
804
|
+
return "[SWITCHMAN:WATERMARK] ctx_handover scheduled: backup fork + handover document + compaction + continuation are running. END this turn now — reply briefly and call no more tools, so the compaction lands at the idle boundary. Your closing line must tell the user the handover is running in the background and will continue automatically (the session-header badge shows the live state) — otherwise the GUI looks idle during fork+compact.";
|
|
805
|
+
},
|
|
806
|
+
presentCall: (args) => ({
|
|
807
|
+
card: "generic",
|
|
808
|
+
title: "Switchman context handover",
|
|
809
|
+
kind: "other",
|
|
810
|
+
rawInput: args,
|
|
811
|
+
}),
|
|
812
|
+
});
|
|
813
|
+
state.handoverTool = "ctx_handover";
|
|
814
|
+
} catch (error) {
|
|
815
|
+
warn(`ctx_handover tool registration failed: ${error?.message ?? error}`);
|
|
816
|
+
}
|
|
618
817
|
}
|
|
619
818
|
|
|
620
819
|
export { applyContextWatch };
|