dsh-session-conductor 1.3.11 → 1.3.16
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.md +23 -1
- package/cordis.patch.yml +0 -0
- package/lib/apply.js +28 -1243
- package/lib/client-parts/apply.js +0 -0
- package/lib/client-parts/components/compaction.js +0 -0
- package/lib/client-parts/components/conductor-settings.js +0 -0
- package/lib/client-parts/components/group.js +0 -0
- package/lib/client-parts/components/panel.js +0 -0
- package/lib/client-parts/components/processing.js +0 -0
- package/lib/client-parts/components/session-icon.js +0 -0
- package/lib/client-parts/components/settings.js +0 -0
- package/lib/client-parts/foundation/bootstrap.js +0 -0
- package/lib/client-parts/foundation/i18n.js +0 -0
- package/lib/client-parts/foundation/list-cache.js +0 -0
- package/lib/client-parts/foundation/styles.js +0 -0
- package/lib/core/config.js +0 -0
- package/lib/core/domain.js +0 -0
- package/lib/core/index-store.js +0 -0
- package/lib/core/lazy.js +0 -0
- package/lib/core/state.js +0 -0
- package/lib/core.js +0 -0
- package/lib/eio-repair.js +0 -0
- package/lib/features/base-routes.js +156 -0
- package/lib/features/context-usage/index.js +0 -0
- package/lib/features/continue/eligibility.js +0 -0
- package/lib/features/continue/scan.js +0 -0
- package/lib/features/continue/session.js +0 -0
- package/lib/features/inject/preset.js +0 -0
- package/lib/features/member-model/batch.js +97 -0
- package/lib/features/member-model/command.js +61 -7
- package/lib/features/member-model/create-teammate.js +0 -0
- package/lib/features/member-model/index.js +15 -1
- package/lib/features/member-model/lead.js +73 -0
- package/lib/features/member-model/locate.js +82 -1
- package/lib/features/member-model/override.js +0 -0
- package/lib/features/member-model/permission-command.js +138 -0
- package/lib/features/member-model/permission.js +213 -0
- package/lib/features/member-model/setup.js +0 -0
- package/lib/features/member-model/switch.js +254 -23
- package/lib/features/member-model/tools.js +324 -0
- package/lib/features/memory-recall/index.js +0 -0
- package/lib/features/rename/analysis.js +0 -0
- package/lib/features/rename/title.js +0 -0
- package/lib/features/rename/value.js +0 -0
- package/lib/features/session-action-routes.js +435 -0
- package/lib/features/session-messaging/index.js +0 -0
- package/lib/features/session-messaging/logic.js +0 -0
- package/lib/features/session-messaging/tools.js +0 -0
- package/lib/features/session-ops-routes.js +0 -0
- package/lib/features/settings-routes.js +400 -0
- package/lib/features/trash.js +0 -0
- package/lib/features/value-index.js +0 -0
- package/lib/features/value-report-routes.js +211 -0
- package/lib/group.js +0 -0
- package/lib/i18n/en.json +0 -0
- package/lib/i18n/zh.json +0 -0
- package/lib/index.js +0 -0
- package/lib/repair.js +0 -0
- package/lib/seq-gap-repair.js +0 -0
- package/lib/session-codec.js +0 -0
- package/lib/session-log.js +0 -0
- package/lib/sessions/detach.js +0 -0
- package/lib/sessions/events.js +0 -0
- package/lib/sessions/interruption.js +0 -0
- package/lib/sessions/list-cache.js +0 -0
- package/lib/sessions/list.js +0 -0
- package/lib/sessions/lookup.js +0 -0
- package/lib/sessions/route.js +0 -0
- package/lib/sessions/session-id.js +0 -0
- package/lib/sessions/tail-io.js +185 -0
- package/lib/sessions/turn.js +0 -0
- package/lib/shared/atomic-file.js +0 -0
- package/lib/shared/constants.js +0 -0
- package/lib/shared/json-store.js +0 -0
- package/lib/shared/log.js +0 -0
- package/lib/shared/timing.js +0 -0
- package/lib/shared/util.js +0 -0
- package/lib/template-inject.js +0 -0
- package/lib/value.js +0 -0
- package/lib/zstd-frames.js +0 -0
- package/package.json +1 -1
- package/scripts/check-member-model.mjs +0 -0
- package/skills/dsh-session-conductor-functions.md +0 -0
- package/skills/dsh-session-conductor.md +0 -0
- package/skills/dsh-session-official-doc.md +0 -0
- package/skills/task-completion-report.md +0 -0
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { readFileSync, copyFileSync, mkdirSync } from "node:fs";
|
|
7
7
|
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { createZstdDecompress } from "node:zlib";
|
|
8
9
|
import { replaceFileSync } from "../../shared/atomic-file.js";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import { log } from "../../shared/log.js";
|
|
@@ -84,36 +85,232 @@ function backupSwitchLog(filePath, sessionId) {
|
|
|
84
85
|
const backupDir = path.join(path.dirname(filePath), ".model-switch-backup");
|
|
85
86
|
try {
|
|
86
87
|
mkdirSync(backupDir, { recursive: true });
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
const file = path.join(backupDir, `${Date.now()}-${sessionId}.zstd`);
|
|
89
|
+
copyFileSync(filePath, file);
|
|
90
|
+
return { ok: true, file };
|
|
89
91
|
} catch (error) {
|
|
90
92
|
return { error: `备份失败(未写入):${String(error?.message ?? error)}` };
|
|
91
93
|
}
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
/** 写后自检不合格时把日志还原回备份(宁可不动日志,也不能留下 corrupt 的会话)。 */
|
|
97
|
+
function restoreSwitchLog(backup, filePath) {
|
|
98
|
+
if (!backup?.file) return { error: "没有可用备份,无法回滚" };
|
|
99
|
+
try {
|
|
100
|
+
copyFileSync(backup.file, filePath);
|
|
101
|
+
return { ok: true };
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return { error: `回滚失败:${String(error?.message ?? error)}` };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 该会话是否带「委派策略快照」,带则返回一条排查提示。
|
|
109
|
+
*
|
|
110
|
+
* 【为什么需要】本机实测:子代理会话开头会有 `approval/policy`(source=delegation,
|
|
111
|
+
* 创建时从父级快照)。若部署侧关掉「自动审查」,宿主恢复这条快照会失败,失败被
|
|
112
|
+
* `manager.coldResume` 统一包成 `subagent/not-resumable` —— 界面只看到
|
|
113
|
+
* 「subagent cannot be resumed」,极具误导性(会让人去查模型/描述符/文件)。
|
|
114
|
+
* 因此切换时若发现该会话带这种快照,就主动提示先去查自动审查开关。
|
|
115
|
+
*
|
|
116
|
+
* 只读首帧(header + 开头几条策略事件),失败一律返回空字符串,绝不影响切换本身。
|
|
117
|
+
*
|
|
118
|
+
* @param {string} filePath 会话日志路径
|
|
119
|
+
* @returns {Promise<string>} 提示文案;不适用或读不到时为空串
|
|
120
|
+
*/
|
|
121
|
+
export async function delegationPolicyHint(filePath) {
|
|
122
|
+
try {
|
|
123
|
+
const { readFile } = await import("node:fs/promises");
|
|
124
|
+
const { scanZstdFrames, decodeFrame } = await import("../../zstd-frames.js");
|
|
125
|
+
const buf = await readFile(filePath);
|
|
126
|
+
const { frames } = scanZstdFrames(buf);
|
|
127
|
+
if (frames.length === 0) return "";
|
|
128
|
+
// 策略事件在会话最开头(header 之后几条),解首帧通常就够
|
|
129
|
+
let text = (await decodeFrame(buf.subarray(frames[0].start, frames[0].end))).toString("utf8");
|
|
130
|
+
if (frames.length > 1) {
|
|
131
|
+
text += (await decodeFrame(buf.subarray(frames[1].start, frames[1].end))).toString("utf8").slice(0, 20000);
|
|
132
|
+
}
|
|
133
|
+
if (text.includes('"type":"approval/policy"') && text.includes('"source":"delegation"')) {
|
|
134
|
+
return '该成员带委派策略快照(approval/policy, source=delegation);若它之后唤不醒并报 subagent/not-resumable,先检查部署侧「自动审查」开关是否被关掉——本机实测这才是真因,与模型/描述符/文件无关';
|
|
135
|
+
}
|
|
136
|
+
return "";
|
|
137
|
+
} catch {
|
|
138
|
+
return "";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 切换模型时同步修正「失效的 descriptor 路由」。
|
|
144
|
+
*
|
|
145
|
+
* 【为什么必须做】宿主 cold resume 子代理时,provider/model 取自会话日志里的
|
|
146
|
+
* `subagent/descriptor`(manager.coldResume → materialize 的 agentOptions),
|
|
147
|
+
* `model/selection` 不参与该路径。若 descriptor 仍指向一个已被删除的 provider
|
|
148
|
+
* (例如配置里移除了某个渠道),materialize 必然失败并被统一包成
|
|
149
|
+
* `subagent/not-resumable` —— 表现为「这个成员永远唤不醒,怎么切模型都没用」。
|
|
150
|
+
* 因此这里在写入 selection 之后顺带校验 descriptor 的路由是否仍存在,失效就同步改掉。
|
|
151
|
+
*
|
|
152
|
+
* 保守原则:拿不到模型目录(服务不可用)或 descriptor 本就有效时,一律不动日志。
|
|
153
|
+
*
|
|
154
|
+
* @returns {Promise<{repaired?: boolean, skipped?: string, error?: string}>}
|
|
155
|
+
*/
|
|
156
|
+
async function repairDescriptorIfStale(ctx, filePath, sessionId, provider, model) {
|
|
157
|
+
const { readDescriptorEvent } = await import("../../sessions/tail-io.js");
|
|
158
|
+
const { readFile } = await import("node:fs/promises");
|
|
159
|
+
let buf;
|
|
160
|
+
try {
|
|
161
|
+
buf = await readFile(filePath);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return { error: `读取会话日志失败:${String(error?.message ?? error)}` };
|
|
164
|
+
}
|
|
165
|
+
let found;
|
|
166
|
+
try {
|
|
167
|
+
found = await readDescriptorEvent(buf);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return { error: `读取 descriptor 失败:${String(error?.message ?? error)}` };
|
|
170
|
+
}
|
|
171
|
+
const descriptor = found.descriptor;
|
|
172
|
+
if (!descriptor || typeof descriptor !== "object") return { skipped: "不是子代理会话(无 descriptor)" };
|
|
173
|
+
|
|
174
|
+
// 取当前可用 provider 列表;拿不到就不猜、不动日志
|
|
175
|
+
const sc = ctx.get("sessionController");
|
|
176
|
+
if (typeof sc?.modelCatalog !== "function") return { skipped: "modelCatalog 不可用,跳过 descriptor 校验" };
|
|
177
|
+
let groups;
|
|
178
|
+
try {
|
|
179
|
+
const catalog = await sc.modelCatalog();
|
|
180
|
+
groups = catalog?.groups ?? [];
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return { skipped: `modelCatalog 调用失败,跳过:${String(error?.message ?? error)}` };
|
|
183
|
+
}
|
|
184
|
+
if (groups.length === 0) return { skipped: "模型目录为空,跳过 descriptor 校验" };
|
|
185
|
+
|
|
186
|
+
const providerIds = new Set(groups.map((g) => g.id));
|
|
187
|
+
const staleProvider = descriptor.agentProvider !== undefined && !providerIds.has(descriptor.agentProvider);
|
|
188
|
+
const staleModel = (() => {
|
|
189
|
+
if (descriptor.agentProvider === undefined || descriptor.agentModel === undefined) return false;
|
|
190
|
+
const group = groups.find((g) => g.id === descriptor.agentProvider);
|
|
191
|
+
if (!group) return false;
|
|
192
|
+
return !(group.models ?? []).some((m) => m.id === descriptor.agentModel);
|
|
193
|
+
})();
|
|
194
|
+
if (!staleProvider && !staleModel) return { skipped: "descriptor 路由有效" };
|
|
195
|
+
|
|
196
|
+
// 整份重写(descriptor 在文件头部,追加帧改不了它);这条路径只在路由失效时走一次,代价可接受
|
|
197
|
+
const { encodeSessionText } = await lazyRepair();
|
|
198
|
+
const { decodeAllFrames } = await import("../../zstd-frames.js");
|
|
199
|
+
const fullText = buf.length > 0 ? await decodeAllFrames(buf) : "";
|
|
200
|
+
const lines = fullText.split("\n").filter((l) => l.trim() !== "");
|
|
201
|
+
if (lines.length === 0) return { error: "会话日志为空,无法修正 descriptor" };
|
|
202
|
+
let patched = 0;
|
|
203
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
204
|
+
let e;
|
|
205
|
+
try { e = JSON.parse(lines[i]); } catch { continue; }
|
|
206
|
+
if (e.type !== "subagent/descriptor") continue;
|
|
207
|
+
e.data.agentProvider = provider;
|
|
208
|
+
e.data.agentModel = model;
|
|
209
|
+
lines[i] = JSON.stringify(e);
|
|
210
|
+
patched += 1;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (patched === 0) return { skipped: "未找到可改写的 descriptor" };
|
|
214
|
+
|
|
215
|
+
// 写前校验 seq 严格连续,避免把会话写成 corrupt
|
|
216
|
+
let prev = -1, gaps = 0;
|
|
217
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
218
|
+
let e;
|
|
219
|
+
try { e = JSON.parse(lines[i]); } catch { return { error: "改写后存在坏行,放弃写盘" }; }
|
|
220
|
+
if (typeof e.seq !== "number") continue;
|
|
221
|
+
if (prev >= 0 && e.seq !== prev + 1) gaps += 1;
|
|
222
|
+
prev = e.seq;
|
|
223
|
+
}
|
|
224
|
+
if (gaps > 0) return { error: `改写后 seq 不连续(${gaps} 处),放弃写盘` };
|
|
225
|
+
|
|
226
|
+
const backup = backupSwitchLog(filePath, sessionId);
|
|
227
|
+
if (backup.error) return { error: backup.error };
|
|
228
|
+
replaceFileSync(filePath, await encodeSessionText(lines.join("\n") + "\n"), { tmpSuffix: ".descfixtmp" });
|
|
229
|
+
log(ctx, `切换写日志:descriptor 路由失效(${staleProvider ? "provider 已不存在" : "model 不在目录"}),已改为 ${provider}/${model}`);
|
|
230
|
+
return { repaired: true };
|
|
231
|
+
}
|
|
232
|
+
|
|
94
233
|
export async function appendSelectionEventToLog(ctx, sessionId, provider, model) {
|
|
95
234
|
const startedAt = Date.now();
|
|
96
|
-
|
|
235
|
+
// 这条路径会整份解码会话日志再整份写回,而宿主在同一会话上会并发追加事件
|
|
236
|
+
// (turn/start、agent/inbox/spliced…)。若直接用「读到的 maxSeq+1」写回,就会写入过期序号,
|
|
237
|
+
// 表现为 released v2 row N has seq gap(序号回退),整份会话随即被判 corrupt。
|
|
238
|
+
// 因此这里做三件事:① 写前 CAS(对比快照是否已过期)② 过期就重读重试 ③ 写后自检 + 失败回滚。
|
|
239
|
+
//
|
|
240
|
+
// 【为什么改成「尾部读 + 追加帧」】原实现每次都整份解码(30MB 日志要 1~3s CPU 与数百 MB 内存),
|
|
241
|
+
// 而追加一条事件只需要知道「尾部 seq」。这里只解码最后几帧拿尾部事件,写入也是「把新事件压缩成一个
|
|
242
|
+
// 新帧追加到文件尾」——开销与日志总大小无关,且不回写已有帧(失败不会破坏历史)。
|
|
243
|
+
const { readTailEvents, appendEventFrame } = await import("../../sessions/tail-io.js");
|
|
244
|
+
let logInfo = await readSwitchLog(sessionId);
|
|
97
245
|
if (logInfo.error) return { error: logInfo.error };
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
246
|
+
const filePath = logInfo.filePath;
|
|
247
|
+
log(ctx, `切换写日志:定位+首读 ${Date.now() - startedAt}ms(行数 ${logInfo.lines.length})`);
|
|
248
|
+
|
|
249
|
+
const tailOf = (events) => ({
|
|
250
|
+
maxSeq: events.reduce((max, e) => (typeof e?.seq === "number" && e.seq > max ? e.seq : max), -1),
|
|
251
|
+
count: events.length,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
255
|
+
const { events } = logInfo;
|
|
256
|
+
const last = foldLastModelSelection(events);
|
|
257
|
+
if (last && last.provider === provider && last.model === model) {
|
|
258
|
+
return { ok: true, note: "日志里已是该模型,无需重复写入" };
|
|
259
|
+
}
|
|
260
|
+
const { maxSeq } = tailOf(events);
|
|
261
|
+
const event = { type: "model/selection", seq: maxSeq + 1, time: Date.now(), data: { provider, model } };
|
|
262
|
+
|
|
263
|
+
// ① CAS:写前再读一次尾部,确认快照期间宿主没有追加新事件(尾部 seq 未变才敢写)
|
|
264
|
+
const { readFile } = await import("node:fs/promises");
|
|
265
|
+
let freshTail;
|
|
266
|
+
try {
|
|
267
|
+
freshTail = await readTailEvents(await readFile(filePath), { tailFrames: 3 });
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return { error: `写前重读失败:${String(error?.message ?? error)}` };
|
|
270
|
+
}
|
|
271
|
+
if (tailOf(freshTail.events).maxSeq !== maxSeq) {
|
|
272
|
+
log(ctx, `切换写日志:快照已过期(第 ${attempt + 1} 次,尾部 seq ${maxSeq} → ${tailOf(freshTail.events).maxSeq}),重读重试`);
|
|
273
|
+
logInfo = { ...logInfo, events: freshTail.events };
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ② 追加帧(appendEventFrame 内部自证首帧契约 + 长度 CAS + fsync)
|
|
278
|
+
const writeStartedAt = Date.now();
|
|
279
|
+
const written = await appendEventFrame(filePath, event);
|
|
280
|
+
log(ctx, `切换写日志:追加帧 ${Date.now() - writeStartedAt}ms(seq ${event.seq})`);
|
|
281
|
+
if (!written.ok) return { error: `写入会话日志失败:${written.error}` };
|
|
282
|
+
// descriptor 路由失效会让成员永久唤不醒(宿主报 subagent/not-resumable),这里顺带修
|
|
283
|
+
const descFix = await repairDescriptorIfStale(ctx, filePath, sessionId, provider, model);
|
|
284
|
+
if (descFix.error) log(ctx, `切换写日志:descriptor 校验失败(不影响本次切换):${descFix.error}`);
|
|
285
|
+
|
|
286
|
+
// 附带排查提示:本机实测「成员唤不醒」的真凶是部署侧的「自动审查」开关被关掉,
|
|
287
|
+
// 与 descriptor / 帧校验和 / 写锁全都无关(详见 docs/VERSIONS.md 1.3.15)。
|
|
288
|
+
// 判据:该会话日志开头存在 `approval/policy` 且 source 为 delegation(创建时从父级快照),
|
|
289
|
+
// 此时若自动审查被关掉,宿主恢复这条委派快照会失败,且失败被 coldResume 统一包成
|
|
290
|
+
// `subagent/not-resumable` —— 表现就是「切什么模型都唤不醒、重启也没用」。
|
|
291
|
+
const policyHint = await delegationPolicyHint(filePath);
|
|
292
|
+
if (policyHint) log(ctx, `切换写日志:${policyHint}`);
|
|
293
|
+
|
|
294
|
+
// ③ 写后自检:只读尾部,确认新事件确实落在尾部且 seq 严格 +1 衔接
|
|
295
|
+
try {
|
|
296
|
+
const after = await readTailEvents(await readFile(filePath), { tailFrames: 3 });
|
|
297
|
+
const seqs = after.events.map((e) => e?.seq).filter((n) => typeof n === "number");
|
|
298
|
+
const tailOk = seqs.length > 0 && seqs.at(-1) === event.seq
|
|
299
|
+
&& seqs.every((n, i) => i === 0 || n === seqs[i - 1] + 1);
|
|
300
|
+
if (tailOk) return { ok: true, note: "已直接写入会话日志" };
|
|
301
|
+
log(ctx, `切换写日志:写后自检失败(尾部 ${JSON.stringify(seqs.slice(-3))}),回滚`);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
log(ctx, `切换写日志:写后自检读取失败:${String(error?.message ?? error)}`);
|
|
304
|
+
}
|
|
305
|
+
// 追加帧写坏时只能整份回退:用备份整份恢复
|
|
306
|
+
const backup = backupSwitchLog(filePath, sessionId);
|
|
307
|
+
if (backup.error) return { error: `写后自检失败且无备份可回滚:${backup.error}` };
|
|
308
|
+
restoreSwitchLog(backup, filePath);
|
|
309
|
+
return { error: "写入后 seq 不连续,已回滚(不改日志,改用选择意图生效)" };
|
|
104
310
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const { validateSessionText, encodeSessionText } = await lazyRepair();
|
|
109
|
-
const check = validateSessionText(newText);
|
|
110
|
-
if (!check.ok) return { error: `写入后校验失败:${check.problems?.[0] ?? "未知"}` };
|
|
111
|
-
const backup = backupSwitchLog(filePath, sessionId);
|
|
112
|
-
if (backup.error) return backup;
|
|
113
|
-
const encodeStartedAt = Date.now();
|
|
114
|
-
replaceFileSync(filePath, await encodeSessionText(newText), { tmpSuffix: ".modelswitchtmp" });
|
|
115
|
-
log(ctx, `切换写日志:重编码+原子写回 ${Date.now() - encodeStartedAt}ms`);
|
|
116
|
-
return { ok: true, note: "已直接写入会话日志" };
|
|
311
|
+
// 一直有并发写入:放弃写日志(不算失败,选择意图仍会生效)
|
|
312
|
+
log(ctx, "切换写日志:并发写入持续存在,放弃写日志(不影响切换)");
|
|
313
|
+
return { ok: true, note: "会话正在被并发写入,跳过日志写入(选择意图仍生效)" };
|
|
117
314
|
}
|
|
118
315
|
|
|
119
316
|
/**
|
|
@@ -337,6 +534,11 @@ function modelEventOf(event) {
|
|
|
337
534
|
* · `model/selection` 的 `data.{provider,model}` —— 切换意图(有则一并返回,便于对照)。
|
|
338
535
|
* 用按 zstd 帧流式读(常量内存),大日志也不会把整份文本读进内存。
|
|
339
536
|
*
|
|
537
|
+
* 【内存纪律】① `pending` 加上限:只在「遇到换行才切走」的缓冲区,若某帧内长时间没有换行
|
|
538
|
+
* (单行超大事件,例如几 MB 的工具结果)会无限增长;超过上限直接抛错,不再硬撑。
|
|
539
|
+
* ② `readFile` 换成只读**最后若干帧**:查证只关心「最近一次请求」,没必要把整份文件读进内存
|
|
540
|
+
* (30MB 日志时这一项就省掉一份整文件 Buffer)。
|
|
541
|
+
*
|
|
340
542
|
* @returns {Promise<{lastRequest: {provider,model,time}|null, lastSelection: {provider,model,time}|null, scanned: number}>}
|
|
341
543
|
*/
|
|
342
544
|
export async function readLastRequestModel(sessionId, root = null) {
|
|
@@ -346,11 +548,23 @@ export async function readLastRequestModel(sessionId, root = null) {
|
|
|
346
548
|
const { streamDecodedFrames } = await import("../../zstd-frames.js");
|
|
347
549
|
const { StringDecoder } = await import("node:string_decoder");
|
|
348
550
|
const buf = await readFile(file); // 日志最大可达数十 MB,异步读避免阻塞事件循环
|
|
551
|
+
const { scanZstdFrames } = await import("../../zstd-frames.js");
|
|
552
|
+
// 只流式解码最后若干帧:请求头/选择意图判定只关心最近一次,够用且省内存
|
|
553
|
+
const TAIL_FRAMES = 8;
|
|
554
|
+
let tailFrames;
|
|
555
|
+
try {
|
|
556
|
+
const { frames } = scanZstdFrames(buf);
|
|
557
|
+
tailFrames = frames.slice(Math.max(0, frames.length - TAIL_FRAMES));
|
|
558
|
+
} catch {
|
|
559
|
+
// 帧扫描失败(文件损坏):退回整份流式解码,让上层按损坏处理,不在这里静默丢数据
|
|
560
|
+
tailFrames = null;
|
|
561
|
+
}
|
|
349
562
|
const decoder = new StringDecoder("utf8");
|
|
350
563
|
let pending = "";
|
|
351
564
|
let scanned = 0;
|
|
352
565
|
let lastRequest = null;
|
|
353
566
|
let lastSelection = null;
|
|
567
|
+
const PENDING_LIMIT = 8 * 1024 * 1024; // 单行缓冲上限:超过即视为异常,避免无上限增长
|
|
354
568
|
const consume = (line) => {
|
|
355
569
|
if (!line) return;
|
|
356
570
|
let event;
|
|
@@ -364,15 +578,32 @@ export async function readLastRequestModel(sessionId, root = null) {
|
|
|
364
578
|
lastSelection = hit.value;
|
|
365
579
|
}
|
|
366
580
|
};
|
|
367
|
-
|
|
581
|
+
const onChunk = (chunk) => {
|
|
368
582
|
pending += decoder.write(chunk);
|
|
583
|
+
if (pending.length > PENDING_LIMIT) {
|
|
584
|
+
throw new Error(`readLastRequestModel 单行缓冲超过 ${PENDING_LIMIT} 字节仍无换行(疑似异常大事件)`);
|
|
585
|
+
}
|
|
369
586
|
let idx;
|
|
370
587
|
while ((idx = pending.indexOf("\n")) >= 0) {
|
|
371
588
|
const line = pending.slice(0, idx);
|
|
372
589
|
pending = pending.slice(idx + 1);
|
|
373
590
|
consume(line);
|
|
374
591
|
}
|
|
375
|
-
}
|
|
592
|
+
};
|
|
593
|
+
if (tailFrames === null) {
|
|
594
|
+
await streamDecodedFrames(buf, onChunk);
|
|
595
|
+
} else {
|
|
596
|
+
for (const fr of tailFrames) {
|
|
597
|
+
await new Promise((resolve, reject) => {
|
|
598
|
+
const d = createZstdDecompress();
|
|
599
|
+
d.on("data", (c) => { try { onChunk(c); } catch (error) { reject(error); } });
|
|
600
|
+
d.on("end", resolve);
|
|
601
|
+
d.on("error", reject);
|
|
602
|
+
d.write(buf.subarray(fr.start, fr.end));
|
|
603
|
+
d.end();
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
376
607
|
pending += decoder.end();
|
|
377
608
|
consume(pending);
|
|
378
609
|
return { lastRequest, lastSelection, scanned };
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// dsh-session-conductor — 成员模型/权限相关的 AI 工具与聊天指令注册
|
|
2
|
+
//
|
|
3
|
+
// 【为什么单独成文件】原先这 293 行全部内联在 lib/apply.js 的 `ctx.inject(["tools"])` 回调里,
|
|
4
|
+
// 使该回调变成一个巨型函数(审计 readability/max-function-length 拦截),
|
|
5
|
+
// 而它内部其实只有两类内容:① 5 个工具的注册(list_models / set_member_model /
|
|
6
|
+
// list_permission_presets / set_member_permission / create_teammate_with_model)
|
|
7
|
+
// ② 两条聊天指令的注册(/model-switch、/perm-switch)。两者都属于「成员模型/权限」这一域,
|
|
8
|
+
// 整体搬出来即可,handler 体与错误隔离策略逐字保留。
|
|
9
|
+
//
|
|
10
|
+
// 【错误隔离约定(必须保留)】每个工具单独包 try/catch:defineTool 抛错会让整个
|
|
11
|
+
// inject 回调中止,历史上因此导致四个工具一起消失(宿主侧表现为「tool 不存在」)。
|
|
12
|
+
import { log } from "../../shared/log.js";
|
|
13
|
+
import { findTargetAgent, resolveTargets, validateModelPair } from "./locate.js";
|
|
14
|
+
import { switchAgentModel, registerSwitchWatch, readLastRequestModel } from "./switch.js";
|
|
15
|
+
import { createTeammateWithModel, renderCreateTeammateResult } from "./create-teammate.js";
|
|
16
|
+
import { switchMemberPermission, describeMemberPermission, readPresetCatalog } from "./permission.js";
|
|
17
|
+
import { readLeaderRoute } from "./lead.js";
|
|
18
|
+
import { applyModelToMany, applyPermissionToMany, renderBatchReport } from "./batch.js";
|
|
19
|
+
import { registerModelSwitchCommand } from "./command.js";
|
|
20
|
+
import { registerContextUsageTool } from "../context-usage/index.js";
|
|
21
|
+
import { registerMemoryRecallTool } from "../memory-recall/index.js";
|
|
22
|
+
import { registerSessionMessagingTools } from "../session-messaging/index.js";
|
|
23
|
+
import { registerPermSwitchCommand } from "./permission-command.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 注册成员模型/权限相关的全部 AI 工具与聊天指令。
|
|
27
|
+
*
|
|
28
|
+
* @param {object} deps
|
|
29
|
+
* @param {object} deps.ctx 插件 context
|
|
30
|
+
* @param {object} deps.tools 宿主 tools 服务(提供 register)
|
|
31
|
+
* @param {Function} deps.defineTool 宿主 defineTool(工作区自测环境可能为 undefined,调用方已判空)
|
|
32
|
+
* @returns {void}
|
|
33
|
+
*/
|
|
34
|
+
export function registerMemberModelTools({ ctx, tools, defineTool }) {
|
|
35
|
+
tools.register(defineTool({
|
|
36
|
+
name: "list_models",
|
|
37
|
+
description: "列出当前可用模型(provider → models[])。切模型前先用它拿到确切 model id,不要凭记忆猜模型名。",
|
|
38
|
+
parameters: {},
|
|
39
|
+
output: {
|
|
40
|
+
schema: { type: "string" },
|
|
41
|
+
render(args, value) {
|
|
42
|
+
return [{ type: "text", text: String(value) }];
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
async execute() {
|
|
46
|
+
try {
|
|
47
|
+
const sc = ctx.get("sessionController");
|
|
48
|
+
if (!sc?.modelCatalog) return "modelCatalog 服务不可用(无法列出模型)";
|
|
49
|
+
const catalog = await sc.modelCatalog();
|
|
50
|
+
const groups = catalog?.groups ?? [];
|
|
51
|
+
if (groups.length === 0) return "模型目录为空(检查 provider 配置)";
|
|
52
|
+
return groups.map((g) => {
|
|
53
|
+
const models = (g.models ?? [])
|
|
54
|
+
.map((m) => ` - ${m.id}${m.name && m.name !== m.id ? `(${m.name})` : ""}`)
|
|
55
|
+
.join("\n");
|
|
56
|
+
return `${g.id}${g.name && g.name !== g.id ? `(${g.name})` : ""}:\n${models}`;
|
|
57
|
+
}).join("\n\n");
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return `列出模型失败:${String(error?.message ?? error)}`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}));
|
|
63
|
+
/**
|
|
64
|
+
* 切换后的「查证快照」:最近一次实际请求 + 最近一次切换意图 + 一行判定。
|
|
65
|
+
* 抽出来是因为这段有 4 个分支,塞在工具 execute 里把圈复杂度顶到 18;
|
|
66
|
+
* 判定只认「请求时真实使用的模型」(request/header),不信注入的提示语。
|
|
67
|
+
* @returns {Promise<{snap: string, verdict: string}>}
|
|
68
|
+
*/
|
|
69
|
+
async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
|
|
70
|
+
const startedAt = Date.now();
|
|
71
|
+
const { lastRequest, lastSelection, scanned } = await readLastRequestModel(sessionId);
|
|
72
|
+
// 读整份日志取「最近一次请求」,大日志上这一步本身就要时间
|
|
73
|
+
log(ctx, `切换查证快照耗时 ${Date.now() - startedAt}ms(命中请求记录 ${scanned ?? 0} 条)`);
|
|
74
|
+
if (!lastRequest) {
|
|
75
|
+
return {
|
|
76
|
+
snap: "【查证·最近一次实际请求】会话日志里还没有 request/header 记录(该成员可能从未运行过)",
|
|
77
|
+
verdict: "【查证·判定】⏳ 待该成员首次运行后生效",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const when = lastRequest.time ? `(${new Date(lastRequest.time).toLocaleString("zh-CN")})` : "";
|
|
81
|
+
const snap = `【查证·最近一次实际请求】${lastRequest.provider}/${lastRequest.model}${when}`
|
|
82
|
+
+ (lastSelection ? `\n【查证·最近一次切换意图】${lastSelection.provider}/${lastSelection.model}` : "");
|
|
83
|
+
const alreadyNew = lastRequest.provider === provider && lastRequest.model === model;
|
|
84
|
+
return {
|
|
85
|
+
snap,
|
|
86
|
+
verdict: alreadyNew
|
|
87
|
+
? "【查证·判定】✅ 该成员最近的请求已经是新模型"
|
|
88
|
+
: "【查证·判定】⏳ 待下一回合生效:最近一次请求是切换前发出的(属正常,不代表失败)",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
tools.register(defineTool({
|
|
93
|
+
name: "set_member_model",
|
|
94
|
+
description: "给成员/子代理切换模型。三个参数都可空:target 留空 = 作用于全部成员(慎用,会改所有子代理);provider/model 留空 = 跟随主代理(Team Lead)当前实际使用的模型。target 传成员名/sessionId/标题关键字。切换前会校验 provider/model 组合是否存在。批量时逐条执行、回执带完整名单。",
|
|
95
|
+
parameters: {
|
|
96
|
+
target: { type: "string", description: "目标成员:成员名、sessionId 或标题关键字。留空 = 全部成员(排除 Lead)" },
|
|
97
|
+
provider: { type: "string", description: "provider 分组名(list_models 的顶层分组)。留空 = 跟随主代理的 provider" },
|
|
98
|
+
model: { type: "string", description: "该 provider 下的模型 id(含前缀)。留空 = 跟随主代理的 model" }
|
|
99
|
+
},
|
|
100
|
+
output: {
|
|
101
|
+
schema: { type: "string" },
|
|
102
|
+
render(args, value) {
|
|
103
|
+
return [{ type: "text", text: String(value) }];
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
async execute(args, exec) {
|
|
107
|
+
const caller = exec?.agent ?? null;
|
|
108
|
+
let provider = String(args?.provider ?? "").trim();
|
|
109
|
+
let model = String(args?.model ?? "").trim();
|
|
110
|
+
// provider/model 留空 = 跟随主代理(Team Lead)当前实际使用的模型。
|
|
111
|
+
// 取值只认「请求级权威记录」,读不到就如实报错,绝不用名册里的静态字段冒充。
|
|
112
|
+
let followedLead = null;
|
|
113
|
+
if (!provider || !model) {
|
|
114
|
+
const lead = await readLeaderRoute(ctx, { caller });
|
|
115
|
+
if (lead.error) return `切换失败:${lead.error}`;
|
|
116
|
+
provider = lead.provider;
|
|
117
|
+
model = lead.model;
|
|
118
|
+
followedLead = lead;
|
|
119
|
+
}
|
|
120
|
+
// 先校验组合有效性——无效组合会让后续回合的模型请求被拒(表现为「切换生效但回合失败」)
|
|
121
|
+
const pair = await validateModelPair(ctx, provider, model);
|
|
122
|
+
if (pair.error) return `切换失败:${pair.error}`;
|
|
123
|
+
// target 留空 = 作用于全部成员;与聊天指令 /model-switch 走同一套目标解析
|
|
124
|
+
const resolved = resolveTargets(ctx, args?.target, { caller });
|
|
125
|
+
if (resolved.error) return `切换失败:${resolved.error}`;
|
|
126
|
+
if (resolved.all) {
|
|
127
|
+
const report = await applyModelToMany(ctx, resolved.targets, { provider, model });
|
|
128
|
+
const extra = [];
|
|
129
|
+
if (followedLead) {
|
|
130
|
+
extra.push(`模型来源:跟随主代理 ${followedLead.sessionId}(${followedLead.modelSource})`);
|
|
131
|
+
for (const w of followedLead.warnings ?? []) extra.push(`提示:${w}`);
|
|
132
|
+
}
|
|
133
|
+
if (pair.note) extra.push(`【校验】${pair.note}`);
|
|
134
|
+
return renderBatchReport(`批量切换模型 → ${provider}/${model}`, report, extra);
|
|
135
|
+
}
|
|
136
|
+
const found = resolved.targets[0];
|
|
137
|
+
const switchResult = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent);
|
|
138
|
+
if (switchResult.error) return `切换失败:${switchResult.error}`;
|
|
139
|
+
const memberLabel = found.member?.name ? `${found.member.name}(${found.sessionId})` : found.sessionId;
|
|
140
|
+
// 查证(注册 job,不阻塞):登记待查证项 → 该成员下次发起请求时由 agent/request 事件判定
|
|
141
|
+
// → 结果注入回本次调用方。同时立刻附一份快照:最近一次实际请求 + 最近一次切换意图。
|
|
142
|
+
// 判定只认「请求时真实使用的模型」(request/header),不信注入的提示语。
|
|
143
|
+
try {
|
|
144
|
+
registerSwitchWatch(found.sessionId, provider, model, exec?.agent?.session?.id ?? null);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
log(ctx, `切换查证登记失败(不影响本次切换): ${String(error?.message ?? error)}`);
|
|
147
|
+
}
|
|
148
|
+
let snap = "";
|
|
149
|
+
let verdict = "";
|
|
150
|
+
try {
|
|
151
|
+
({ snap, verdict } = await describeSwitchSnapshot(ctx, found.sessionId, provider, model));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
snap = `【查证·快照】读取会话日志失败:${String(error?.message ?? error)}`;
|
|
154
|
+
}
|
|
155
|
+
// 复核命令按**当前插件安装位置**生成(不写死本机路径),可直接复制执行
|
|
156
|
+
const scriptUrl = new URL("../scripts/check-member-model.mjs", import.meta.url);
|
|
157
|
+
const cmd = `node ${decodeURIComponent(scriptUrl.pathname)} ${found.sessionId}`;
|
|
158
|
+
return [
|
|
159
|
+
`已把成员 ${memberLabel}(${found.matched} 匹配)切到 ${provider}/${model};生效方式:${switchResult.via}`,
|
|
160
|
+
// 组合校验默认关闭(避免 @Remote 卡顿);跳过时如实告知,别让人以为「拼错也会被拦住」
|
|
161
|
+
pair.note ? `【校验】${pair.note}` : "",
|
|
162
|
+
snap,
|
|
163
|
+
verdict,
|
|
164
|
+
"【查证·任务】已登记(不阻塞):该成员下次发起请求时按事件判定,结果会在下一次 step 注入给你",
|
|
165
|
+
`复核命令(可直接复制):\n${cmd}`,
|
|
166
|
+
].filter(Boolean).join("\n");
|
|
167
|
+
}
|
|
168
|
+
}));
|
|
169
|
+
// 列出宿主当前可选的权限预设。存在的意义是让 set_member_permission 的 preset 参数
|
|
170
|
+
// 有可查来源,避免凭记忆猜名字(不可用的预设名写进会话会让成员加载失败)。
|
|
171
|
+
try {
|
|
172
|
+
tools.register(defineTool({
|
|
173
|
+
name: "list_permission_presets",
|
|
174
|
+
description: "列出宿主当前可选的权限预设(permission preset)名称与默认值。给成员切换权限前先用它确认确切名字。",
|
|
175
|
+
parameters: {},
|
|
176
|
+
output: {
|
|
177
|
+
schema: { type: "string" },
|
|
178
|
+
render(args, value) {
|
|
179
|
+
return [{ type: "text", text: String(value) }];
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
async execute() {
|
|
183
|
+
const catalog = await readPresetCatalog(ctx);
|
|
184
|
+
if (catalog.error) return `读取失败:${catalog.error}`;
|
|
185
|
+
const lines = [`可选权限预设(共 ${catalog.names.length} 个)`, ""];
|
|
186
|
+
for (const name of catalog.names) lines.push(` - ${name}`);
|
|
187
|
+
if (catalog.defaultPreset) lines.push("", `新会话默认预设:${catalog.defaultPreset}`);
|
|
188
|
+
if (catalog.names.includes("auto")) {
|
|
189
|
+
lines.push("", "注意:auto 依赖「自动审查」集成;该功能一旦关闭,带 auto 的会话会无法恢复(宿主报 subagent/not-resumable)。");
|
|
190
|
+
}
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
}
|
|
193
|
+
}));
|
|
194
|
+
} catch (error) {
|
|
195
|
+
log(ctx, `list_permission_presets 工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
|
|
196
|
+
}
|
|
197
|
+
// 查看/切换成员(子代理)的权限预设。
|
|
198
|
+
// 子代理会话在界面上无法手动切权限预设(界面只对主会话开放),而 preset 一旦指向
|
|
199
|
+
// 当前不可用的值(典型:auto 在「自动审查」关闭后悬空),该成员就永久唤不醒——
|
|
200
|
+
// 宿主 permission-presets 在会话发布前抛错,再被 subagent 的 coldResume 统一包成
|
|
201
|
+
// `subagent/not-resumable`,报错文本完全指不到权限层。此工具提供不改界面的修正通道。
|
|
202
|
+
// 与其它工具一致:render 必须放在 output 里;单独包 try/catch,避免一个工具抛错
|
|
203
|
+
// 让整个 inject("tools") 中止(历史上四个工具一起消失过)。
|
|
204
|
+
try {
|
|
205
|
+
tools.register(defineTool({
|
|
206
|
+
name: "set_member_permission",
|
|
207
|
+
description: "给成员/子代理切换权限预设(写在该成员会话日志里,下次加载生效)。子代理无法在界面上手动切换权限预设,只能用这个工具。两个参数都可空:target 留空 = 作用于全部成员(慎用);preset 留空 = 跟随主代理(Team Lead)当前预设。preset 必须在宿主当前可选列表里,可用 list_permission_presets 查。批量时逐条执行、回执带完整名单。",
|
|
208
|
+
parameters: {
|
|
209
|
+
target: { type: "string", description: "目标成员:成员名、sessionId 或标题关键字。留空 = 全部成员(排除 Lead)" },
|
|
210
|
+
preset: { type: "string", description: "目标权限预设名(如 danger-full-access)。留空 = 跟随主代理的预设" }
|
|
211
|
+
},
|
|
212
|
+
output: {
|
|
213
|
+
schema: { type: "string" },
|
|
214
|
+
render(args, value) {
|
|
215
|
+
return [{ type: "text", text: String(value) }];
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
async execute(args, exec) {
|
|
219
|
+
const caller = exec?.agent ?? null;
|
|
220
|
+
let preset = String(args?.preset ?? "").trim();
|
|
221
|
+
// preset 留空 = 跟随主代理(Team Lead)当前预设;主代理自己没记录预设时如实报错,
|
|
222
|
+
// 不猜一个默认值塞进去(写错预设会让成员加载失败)。
|
|
223
|
+
let followedLead = null;
|
|
224
|
+
if (!preset) {
|
|
225
|
+
const lead = await readLeaderRoute(ctx, { caller });
|
|
226
|
+
if (lead.error) return `切换失败:${lead.error}`;
|
|
227
|
+
if (!lead.preset) return `切换失败:主代理(${lead.sessionId})没有记录权限预设,无法跟随;请显式指定 preset(可用 list_permission_presets 查)`;
|
|
228
|
+
preset = lead.preset;
|
|
229
|
+
followedLead = lead;
|
|
230
|
+
}
|
|
231
|
+
// target 留空 = 作用于全部成员
|
|
232
|
+
const resolved = resolveTargets(ctx, args?.target, { caller });
|
|
233
|
+
if (resolved.error) return `切换失败:${resolved.error}`;
|
|
234
|
+
if (resolved.all) {
|
|
235
|
+
const report = await applyPermissionToMany(ctx, resolved.targets, preset);
|
|
236
|
+
const extra = [];
|
|
237
|
+
if (followedLead) {
|
|
238
|
+
extra.push(`预设来源:跟随主代理 ${followedLead.sessionId}(${followedLead.preset})`);
|
|
239
|
+
for (const w of followedLead.warnings ?? []) extra.push(`提示:${w}`);
|
|
240
|
+
}
|
|
241
|
+
return renderBatchReport(`批量切换权限预设 → ${preset}`, report, extra);
|
|
242
|
+
}
|
|
243
|
+
const found = resolved.targets[0];
|
|
244
|
+
const label = found.member?.name ? `${found.member.name}(${found.sessionId})` : found.sessionId;
|
|
245
|
+
const result = await switchMemberPermission(ctx, found.sessionId, preset);
|
|
246
|
+
if (result.error) return `切换失败:${result.error}`;
|
|
247
|
+
const lines = [
|
|
248
|
+
`已把成员 ${label} 的权限预设切成 ${result.to}`,
|
|
249
|
+
`原预设:${result.from ?? "(未记录)"} | 新事件 seq:${result.seq}`,
|
|
250
|
+
];
|
|
251
|
+
if (followedLead) lines.push(`预设来源:跟随主代理 ${followedLead.sessionId}`);
|
|
252
|
+
if (result.note) lines.push(`说明:${result.note}`);
|
|
253
|
+
lines.push(`当前旋钮:sandbox=${result.knobs?.sandbox ?? "(未记录)"} | approval=${result.knobs?.approval ?? "(未记录)"}`);
|
|
254
|
+
lines.push("改动写在会话日志里,成员下次加载/唤醒时按折叠语义生效(最后一条 preset 事件为准)。");
|
|
255
|
+
return lines.join("\n");
|
|
256
|
+
}
|
|
257
|
+
}));
|
|
258
|
+
} catch (error) {
|
|
259
|
+
log(ctx, `set_member_permission 工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
|
|
260
|
+
}
|
|
261
|
+
// 创建成员并同时指定模型:宿主 spawn_teammate 没有 provider/model 参数,这里补齐。
|
|
262
|
+
// 形状必须与其它工具一致(render 放在 output 里);且单独包 try/catch——
|
|
263
|
+
// 这个回调里任何一个 defineTool 抛错都会让整个 inject("tools") 中止,四个工具一起消失。
|
|
264
|
+
try {
|
|
265
|
+
tools.register(defineTool({
|
|
266
|
+
name: "create_teammate_with_model",
|
|
267
|
+
description: "创建团队成员,并让第一轮真实任务就用指定模型(宿主 spawn_teammate 没有 provider/model 参数,此工具补齐)。内部流程:占位任务创建 → 立刻中断(避免用默认模型把首轮跑掉)→ 写 model/selection → 投递真实任务。只能由 Team Lead 调用。",
|
|
268
|
+
parameters: {
|
|
269
|
+
name: { type: "string", required: true, description: "成员名(lower-kebab-case,唯一)" },
|
|
270
|
+
description: { type: "string", required: true, description: "职责简述" },
|
|
271
|
+
prompt: { type: "string", required: true, description: "该成员的初始任务全文" },
|
|
272
|
+
provider: { type: "string", required: true, description: "模型 provider 分组名(agnes / wb / d …;先用 list_models 查确切值)" },
|
|
273
|
+
model: { type: "string", required: true, description: "模型 id(先用 list_models 查确切值)" },
|
|
274
|
+
subagentProvider: { type: "string", description: "子代理 provider(与模型 provider 不是一回事;默认取 ctx.subagents.list() 第一个,通常就是 spawn)。一般不用填" },
|
|
275
|
+
context: { type: "string", description: "上下文来源:fresh(默认,不继承)或 fork(继承调用方已有回合)" },
|
|
276
|
+
},
|
|
277
|
+
output: {
|
|
278
|
+
schema: { type: "string" },
|
|
279
|
+
render(args, value) {
|
|
280
|
+
return [{ type: "text", text: String(value) }];
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
async execute(args, exec) {
|
|
284
|
+
const result = await createTeammateWithModel(ctx, exec, args);
|
|
285
|
+
return renderCreateTeammateResult(args, result);
|
|
286
|
+
},
|
|
287
|
+
}));
|
|
288
|
+
} catch (error) {
|
|
289
|
+
log(ctx, `create_teammate_with_model 注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 上下文用量自查工具:让 AI 自己按需查占用与构成,而不是每轮把数值注入系统提示词
|
|
293
|
+
// (每轮注入会让系统提示词前缀每轮变化 → prompt cache 失效 → 成本上升)。
|
|
294
|
+
if (registerContextUsageTool(ctx, defineTool, tools)) {
|
|
295
|
+
log(ctx, "上下文用量工具已注册 (context_usage)");
|
|
296
|
+
}
|
|
297
|
+
// 跨会话消息 + 指定模型新建会话(三个工具一次注册)。
|
|
298
|
+
// 整体包 try/catch:注册期抛错会连累同批其它工具(本项目历史事故),失败只留痕、不阻断。
|
|
299
|
+
try {
|
|
300
|
+
if (registerSessionMessagingTools(ctx, defineTool, tools)) {
|
|
301
|
+
log(ctx, "跨会话工具已注册 (list_sessions / send_to_session / create_session)");
|
|
302
|
+
}
|
|
303
|
+
} catch (error) {
|
|
304
|
+
log(ctx, `跨会话工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
// 记忆取回的手动通道:让 AI 自己按需回溯(自动注入之外的补充)
|
|
308
|
+
if (registerMemoryRecallTool(ctx, defineTool, tools)) {
|
|
309
|
+
log(ctx, "记忆取回工具已注册 (recall_memory)");
|
|
310
|
+
}
|
|
311
|
+
} catch (error) {
|
|
312
|
+
log(ctx, `记忆取回工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
|
|
313
|
+
}
|
|
314
|
+
log(ctx, "成员模型工具已注册 (list_models / set_member_model)");
|
|
315
|
+
// 聊天框指令 `/model-switch`:用户自己就能看/切成员模型的入口(与 AI 工具同一套切换 + 查证路径)。
|
|
316
|
+
// 异步注册 + 独立兜底:宿主 commands 服务或 brand 包不可用时只留痕,绝不影响其它功能(同既有约定)。
|
|
317
|
+
registerModelSwitchCommand(ctx)
|
|
318
|
+
.then((ok) => { if (ok) log(ctx, "聊天框指令已注册 (/model-switch)"); })
|
|
319
|
+
.catch((error) => log(ctx, `聊天框指令注册失败(不影响其它功能): ${String(error?.message ?? error)}`));
|
|
320
|
+
// 聊天框指令 `/perm-switch`:子代理在界面上切不了权限预设,只能靠这条写会话日志的通道。
|
|
321
|
+
registerPermSwitchCommand(ctx)
|
|
322
|
+
.then((ok) => { if (ok) log(ctx, "聊天框指令已注册 (/perm-switch)"); })
|
|
323
|
+
.catch((error) => log(ctx, `权限指令注册失败(不影响其它功能): ${String(error?.message ?? error)}`));
|
|
324
|
+
}
|
|
File without changes
|