dsh-session-conductor 1.3.19 → 1.4.5
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 +12 -1
- package/lib/apply.js +22 -8
- package/lib/client.js +17 -5
- package/lib/core/domain-fallback.js +93 -0
- package/lib/core/domain.js +37 -7
- package/lib/features/member-model/batch.js +2 -2
- package/lib/features/member-model/command.js +7 -5
- package/lib/features/member-model/create-teammate.js +3 -1
- package/lib/features/member-model/override.js +35 -0
- package/lib/features/member-model/switch.js +151 -47
- package/lib/features/member-model/tools.js +5 -3
- package/lib/features/memory-recall/index.js +10 -36
- package/lib/features/session-ops-routes.js +21 -0
- package/lib/sessions/export-restore.js +384 -0
- package/lib/sessions/member-trace.js +9 -4
- package/lib/sessions/resolve.js +120 -0
- package/package.json +1 -1
|
@@ -14,7 +14,7 @@ import { cfg } from "../../core/config.js";
|
|
|
14
14
|
import { hasOpenTurn } from "../../sessions/turn.js";
|
|
15
15
|
import { foldLastModelSelection } from "../../sessions/route.js";
|
|
16
16
|
import { lazyZstd, lazyRepair } from "../../core/lazy.js";
|
|
17
|
-
import { setMemberModelOverride } from "./override.js";
|
|
17
|
+
import { setMemberModelOverride, deleteMemberModelOverride } from "./override.js";
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* 等待一个成员进入空闲(当前回合结束/暂停)。
|
|
@@ -171,29 +171,38 @@ async function repairDescriptorIfStale(ctx, filePath, sessionId, provider, model
|
|
|
171
171
|
const descriptor = found.descriptor;
|
|
172
172
|
if (!descriptor || typeof descriptor !== "object") return { skipped: "不是子代理会话(无 descriptor)" };
|
|
173
173
|
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
174
|
+
// 何时改写 descriptor:目标模型 ≠ descriptor 当前值就改 ——
|
|
175
|
+
// 子代理冷恢复(reason=resume)的请求模型直接取 descriptor,不经过官方
|
|
176
|
+
// model/selection 投影链路,selection 事件对它无效(只写 selection 时 resume 仍用
|
|
177
|
+
// descriptor 旧模型)。要「不带 override 也能切子代理」,必须把 descriptor 同步到目标,
|
|
178
|
+
// 两条通道(挂载期 selection / 冷恢复 descriptor)才都生效。
|
|
179
|
+
const driftsFromTarget = descriptor.agentProvider === undefined
|
|
180
|
+
|| descriptor.agentModel === undefined
|
|
181
|
+
|| descriptor.agentProvider !== provider
|
|
182
|
+
|| descriptor.agentModel !== model;
|
|
183
|
+
if (!driftsFromTarget) return { skipped: "descriptor 已指向目标模型" };
|
|
184
|
+
|
|
185
|
+
// modelCatalog 只用于**校验目标合法**(防止把 descriptor 写成一个不存在的路由,
|
|
186
|
+
// 那会让成员永久唤不醒)。它不可用时不再直接放弃同步 —— 早先版本把它当硬前置,
|
|
187
|
+
// 结果「目录服务不可用」= 同步静默失效,成员冷恢复又回落旧模型。
|
|
188
|
+
const sc = ctx.get?.("sessionController");
|
|
189
|
+
if (typeof sc?.modelCatalog === "function") {
|
|
190
|
+
try {
|
|
191
|
+
const catalog = await sc.modelCatalog();
|
|
192
|
+
const groups = catalog?.groups ?? [];
|
|
193
|
+
if (groups.length > 0) {
|
|
194
|
+
const group = groups.find((g) => g.id === provider);
|
|
195
|
+
if (!group) return { skipped: `目标 provider ${provider} 不在模型目录,跳过(避免写入失效路由)` };
|
|
196
|
+
const known = (group.models ?? []).some((m) => m.id === model);
|
|
197
|
+
if (!known) return { skipped: `目标模型 ${model} 不在 ${provider} 目录,跳过(避免写入失效路由)` };
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
// 目录调用失败不阻断同步:目标值由调用方给出,宿主侧还有组合校验兜底
|
|
201
|
+
ctx.logger?.info?.(`dsh-session-conductor: descriptor 同步:模型目录调用失败,仍按目标写入:${String(error?.message ?? error)}`);
|
|
202
|
+
}
|
|
183
203
|
}
|
|
184
|
-
|
|
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 在文件头部,追加帧改不了它);这条路径只在路由失效时走一次,代价可接受
|
|
204
|
+
|
|
205
|
+
// 整份重写(descriptor 在文件头部,追加帧改不了它);这条路径只在路由失效或目标漂移时走一次,代价可接受
|
|
197
206
|
const { encodeSessionText } = await lazyRepair();
|
|
198
207
|
const { decodeAllFrames } = await import("../../zstd-frames.js");
|
|
199
208
|
const fullText = buf.length > 0 ? await decodeAllFrames(buf) : "";
|
|
@@ -226,7 +235,7 @@ async function repairDescriptorIfStale(ctx, filePath, sessionId, provider, model
|
|
|
226
235
|
const backup = backupSwitchLog(filePath, sessionId);
|
|
227
236
|
if (backup.error) return { error: backup.error };
|
|
228
237
|
replaceFileSync(filePath, await encodeSessionText(lines.join("\n") + "\n"), { tmpSuffix: ".descfixtmp" });
|
|
229
|
-
log(ctx, `切换写日志:descriptor
|
|
238
|
+
log(ctx, `切换写日志:descriptor 已同步为 ${provider}/${model}(原 ${descriptor.agentProvider ?? "?"}/${descriptor.agentModel ?? "?"},防冷恢复回落)`);
|
|
230
239
|
return { repaired: true };
|
|
231
240
|
}
|
|
232
241
|
|
|
@@ -251,6 +260,17 @@ export async function appendSelectionEventToLog(ctx, sessionId, provider, model)
|
|
|
251
260
|
count: events.length,
|
|
252
261
|
});
|
|
253
262
|
|
|
263
|
+
// descriptor 同步放在「selection 是否已相同」的判定**之前**:
|
|
264
|
+
// 两者是独立通道(selection 管挂载期、descriptor 管冷恢复),日志里 selection 已是目标值
|
|
265
|
+
// 并不代表 descriptor 也对齐了。早先放在追加帧之后,遇到「重复切换同一模型」就提前返回,
|
|
266
|
+
// 于是 descriptor 永远修不到,成员下次 resume 仍回落旧模型。
|
|
267
|
+
const descFixEarly = await repairDescriptorIfStale(ctx, filePath, sessionId, provider, model);
|
|
268
|
+
if (descFixEarly.repaired) {
|
|
269
|
+
log(ctx, `切换写日志:descriptor 已同步到 ${provider}/${model}(防冷恢复回落)`);
|
|
270
|
+
} else if (descFixEarly.error) {
|
|
271
|
+
log(ctx, `切换写日志:descriptor 校验失败(不影响本次切换):${descFixEarly.error}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
254
274
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
255
275
|
const { events } = logInfo;
|
|
256
276
|
const last = foldLastModelSelection(events);
|
|
@@ -279,9 +299,8 @@ export async function appendSelectionEventToLog(ctx, sessionId, provider, model)
|
|
|
279
299
|
const written = await appendEventFrame(filePath, event);
|
|
280
300
|
log(ctx, `切换写日志:追加帧 ${Date.now() - writeStartedAt}ms(seq ${event.seq})`);
|
|
281
301
|
if (!written.ok) return { error: `写入会话日志失败:${written.error}` };
|
|
282
|
-
// descriptor
|
|
283
|
-
|
|
284
|
-
if (descFix.error) log(ctx, `切换写日志:descriptor 校验失败(不影响本次切换):${descFix.error}`);
|
|
302
|
+
// descriptor 同步已在进入循环前做过一次(那时 selection 可能是「已是该模型」而提前返回),
|
|
303
|
+
// 这里不再重复整份重写 —— 只有追加帧改变了文件尾部,descriptor 在头部、不受影响。
|
|
285
304
|
|
|
286
305
|
// 附带排查提示:本机实测「成员唤不醒」的真凶是部署侧的「自动审查」开关被关掉,
|
|
287
306
|
// 与 descriptor / 帧校验和 / 写锁全都无关(详见 docs/VERSIONS.md 1.3.15)。
|
|
@@ -334,12 +353,30 @@ export async function appendSelectionEventToLog(ctx, sessionId, provider, model)
|
|
|
334
353
|
* ① 持久化切换意图:不依赖 live agent —— 成员 inactive/未挂载时也能切,下次运行生效。
|
|
335
354
|
* @returns {Promise<{note: string}>} note 直接拼进回执
|
|
336
355
|
*/
|
|
337
|
-
async function persistSwitchIntent(ctx, sessionId, provider, model) {
|
|
356
|
+
async function persistSwitchIntent(ctx, sessionId, provider, model, options = {}) {
|
|
357
|
+
// override 是**显式参数**:带 `override` → 写入/更新(旧行为,每轮强制改写);
|
|
358
|
+
// 不带 → **删除该成员的存量 override**,让官方 model/selection 链路接管。
|
|
359
|
+
// 为什么必须删而不是「不写」:override 一旦存在,agent/request 会每轮按它改写请求,
|
|
360
|
+
// 优先级压过新写的 model/selection,结果就是「selection 写入成功但下一条请求仍用旧模型」。
|
|
361
|
+
// 只删该成员这一条,不做批量清理。
|
|
362
|
+
if (options?.override === true) {
|
|
363
|
+
try {
|
|
364
|
+
await setMemberModelOverride(ctx, sessionId, provider, model);
|
|
365
|
+
return { note: ",已写 override(每轮强制改写,旧行为;UI 切换依赖同步机制)" };
|
|
366
|
+
} catch (error) {
|
|
367
|
+
return { note: `,override 持久化失败:${String(error?.message ?? error)}` };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
338
370
|
try {
|
|
339
|
-
await
|
|
340
|
-
return { note:
|
|
371
|
+
const { removed, error } = await deleteMemberModelOverride(ctx, sessionId);
|
|
372
|
+
if (error) return { note: `,清除 override 失败(旧 override 仍会压制切换):${error}` };
|
|
373
|
+
return {
|
|
374
|
+
note: removed
|
|
375
|
+
? ",已清除该成员的 override(改走官方 model/selection 链路)"
|
|
376
|
+
: ",未写 override(走官方 model/selection 链路)",
|
|
377
|
+
};
|
|
341
378
|
} catch (error) {
|
|
342
|
-
return { note:
|
|
379
|
+
return { note: `,清除 override 失败:${String(error?.message ?? error)}` };
|
|
343
380
|
}
|
|
344
381
|
}
|
|
345
382
|
|
|
@@ -382,10 +419,40 @@ async function tryOfficialSelectModel(ctx, sessionId, provider, model, overrideN
|
|
|
382
419
|
}
|
|
383
420
|
}
|
|
384
421
|
|
|
385
|
-
|
|
422
|
+
/**
|
|
423
|
+
* 把切换结果同步到 live agent 的 `options`(宿主官方「查看子智能体」视图读的就是它)。
|
|
424
|
+
*
|
|
425
|
+
* 【为什么需要】宿主 experimental/agent-team 的 `memberView()` 里,成员模型取的是
|
|
426
|
+
* `live.options.model` —— 即 agent **创建时**的 AgentOptions,与我们写的 `model/selection`
|
|
427
|
+
* 事件无关。不同步的话会出现「界面一直显示创建时的老模型,而实际请求早已换了模型」,
|
|
428
|
+
* 两边长期对不上,排查时极易误判。
|
|
429
|
+
*
|
|
430
|
+
* 【为什么能改】宿主 ReactLoopAgent 构造里是 `this.options = options` 直接引用、**没有 freeze**
|
|
431
|
+
* (该文件只冻结 boundaryMessages 与请求标记)。这里仍做 `isFrozen` 兜底:
|
|
432
|
+
* 万一将来宿主冻结了,跳过同步即可,绝不影响切换本身。
|
|
433
|
+
*
|
|
434
|
+
* @returns {boolean} 是否真的写进去了
|
|
435
|
+
*/
|
|
436
|
+
export function syncLiveAgentOptions(ctx, live, provider, model) {
|
|
437
|
+
if (!live || !live.options) return false;
|
|
438
|
+
try {
|
|
439
|
+
if (Object.isFrozen(live.options)) {
|
|
440
|
+
log(ctx, "成员模型切换:live agent 的 options 已被冻结,跳过视图同步(不影响实际请求)");
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
live.options.provider = provider;
|
|
444
|
+
live.options.model = model;
|
|
445
|
+
return true;
|
|
446
|
+
} catch (error) {
|
|
447
|
+
log(ctx, `成员模型切换:同步 live options 失败(不影响实际请求): ${String(error?.message ?? error)}`);
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export async function switchAgentModel(ctx, sessionId, provider, model, agent = null, options = {}) {
|
|
386
453
|
const timer = createStageTimer();
|
|
387
454
|
try {
|
|
388
|
-
return await applyModelSwitch(ctx, sessionId, provider, model, agent, timer);
|
|
455
|
+
return await applyModelSwitch(ctx, sessionId, provider, model, agent, timer, options);
|
|
389
456
|
} finally {
|
|
390
457
|
// 无论成功失败都留一行分阶段耗时:排查「切模型卡很久」时直接看慢在哪一段
|
|
391
458
|
log(ctx, `模型切换耗时:${timer.summary()}(标记合计 ${timer.total()}ms)`);
|
|
@@ -393,20 +460,27 @@ export async function switchAgentModel(ctx, sessionId, provider, model, agent =
|
|
|
393
460
|
}
|
|
394
461
|
|
|
395
462
|
/** 切换主流程(逐段打点,计时器只观测、不改变行为)。 */
|
|
396
|
-
async function applyModelSwitch(ctx, sessionId, provider, model, agent, timer) {
|
|
463
|
+
async function applyModelSwitch(ctx, sessionId, provider, model, agent, timer, options = {}) {
|
|
397
464
|
if (!sessionId) return { error: "缺少 sessionId(无法定位目标会话)" };
|
|
398
|
-
// ① 持久化切换意图(不依赖 live agent——成员 inactive
|
|
399
|
-
|
|
465
|
+
// ① 持久化切换意图(不依赖 live agent——成员 inactive/未挂载时也能切,下次运行生效)。
|
|
466
|
+
// 只有显式传 override:true 才写 override(旧行为);默认走官方 model/selection 链路。
|
|
467
|
+
const { note: overrideNote } = await persistSwitchIntent(ctx, sessionId, provider, model, options);
|
|
400
468
|
timer.mark("写意图");
|
|
401
469
|
const live = agent ?? ctx.get("agents")?.get?.(sessionId) ?? null;
|
|
402
470
|
timer.mark("定位成员");
|
|
403
|
-
// ②
|
|
404
|
-
//
|
|
405
|
-
|
|
471
|
+
// ② 成员真的没有被实例化(agents 里取不到、或没有 session):只能把事件写进会话日志,
|
|
472
|
+
// 官方 selectionFor 读投影即生效。
|
|
473
|
+
//
|
|
474
|
+
// 【判定为什么只看 session】旧条件还要求 `typeof live.ctx.on === "function"`,但 agent 空闲后
|
|
475
|
+
// `ctx.scope` 已 unwind,`live.ctx.on` 不再可用 —— 于是「空闲但仍在内存」的成员被误判成未挂载,
|
|
476
|
+
// 走这条分支就永远拿不到 live.options 去同步(官方 list_agents / 查看子智能体显示的模型会一直
|
|
477
|
+
// 停在创建时的值)。实测:官方 list_agents 对空闲成员仍能返回 model 字段,说明 agents.get() 有对象,
|
|
478
|
+
// 那就是能同步的。写事件与同步 options 都只需要 session,不需要活跃的 ctx。
|
|
479
|
+
if (!live?.session) {
|
|
406
480
|
const written = await appendSelectionEventToLog(ctx, sessionId, provider, model);
|
|
407
481
|
timer.mark("写会话日志");
|
|
408
482
|
if (written.error) {
|
|
409
|
-
return { error: `成员未挂载,且写入会话日志失败:${written.error}
|
|
483
|
+
return { error: `成员未挂载,且写入会话日志失败:${written.error}${overrideNote},可稍后重试` };
|
|
410
484
|
}
|
|
411
485
|
return { via: `已写入会话日志的 model/selection 事件(成员未挂载)${written.note ? "," + written.note : ""}${overrideNote},该成员下次运行时生效` };
|
|
412
486
|
}
|
|
@@ -430,7 +504,25 @@ async function applyModelSwitch(ctx, sessionId, provider, model, agent, timer) {
|
|
|
430
504
|
} catch (error) {
|
|
431
505
|
eventNote = `,事件写入失败:${String(error?.message ?? error)}`;
|
|
432
506
|
}
|
|
433
|
-
|
|
507
|
+
// ④-补:把结果同步进 live agent 的 options —— 官方「查看子智能体」视图显示的是它
|
|
508
|
+
// (memberView 读 live.options.model),不跟着改就会一直显示创建时的老模型。
|
|
509
|
+
const synced = syncLiveAgentOptions(ctx, live, provider, model);
|
|
510
|
+
timer.mark("同步视图");
|
|
511
|
+
// ④-补2:descriptor 同步 —— 挂载中切换只写 selection,但成员空闲卸载后冷恢复(resume)
|
|
512
|
+
// 的请求模型直接取 descriptor,不改它就会「下次唤醒回落旧模型」。与未挂载路径
|
|
513
|
+
// (appendSelectionEventToLog 里的修复)保持同一语义:目标 ≠ descriptor 就改写。
|
|
514
|
+
try {
|
|
515
|
+
const { findSessionLogById, sessionsRootOf } = await import("../../sessions/events.js");
|
|
516
|
+
const logPath = findSessionLogById(sessionId, sessionsRootOf());
|
|
517
|
+
if (logPath) {
|
|
518
|
+
const descFix = await repairDescriptorIfStale(ctx, logPath, sessionId, provider, model);
|
|
519
|
+
if (descFix.repaired) log(ctx, `成员模型切换:descriptor 已同步到 ${provider}/${model}(防冷恢复回落)`);
|
|
520
|
+
}
|
|
521
|
+
} catch (error) {
|
|
522
|
+
log(ctx, `成员模型切换:descriptor 同步失败(不影响本次切换): ${String(error?.message ?? error)}`);
|
|
523
|
+
}
|
|
524
|
+
let via = `model/selection 事件(官方投影接管)${eventNote}${overrideNote},下一回合生效`
|
|
525
|
+
+ (synced ? ";已同步成员视图的模型显示" : "");
|
|
434
526
|
// ⑤ 官方接口优先(若该部署暴露):
|
|
435
527
|
// · 新版:ctx.apiProxy.sessions.selectModel(会话级模型选择,持久化到会话)
|
|
436
528
|
// · 旧版:ctx.get("sessionController").selectModel(Typert @Remote,服务端直调可能不可用)
|
|
@@ -549,8 +641,10 @@ export async function readLastRequestModel(sessionId, root = null) {
|
|
|
549
641
|
const { StringDecoder } = await import("node:string_decoder");
|
|
550
642
|
const buf = await readFile(file); // 日志最大可达数十 MB,异步读避免阻塞事件循环
|
|
551
643
|
const { scanZstdFrames } = await import("../../zstd-frames.js");
|
|
552
|
-
//
|
|
553
|
-
|
|
644
|
+
// 只流式解码尾部若干帧:判定只关心最近一次,够用且省内存。
|
|
645
|
+
// 【为什么不是 8】一个回合常有上百个事件(step/tool/assistant 各占一帧),
|
|
646
|
+
// 请求头往往落在倒数几十帧之外;取 8 帧会把「跑过很多轮」的会话误报成「从未运行过」。
|
|
647
|
+
const TAIL_FRAMES = 400;
|
|
554
648
|
let tailFrames;
|
|
555
649
|
try {
|
|
556
650
|
const { frames } = scanZstdFrames(buf);
|
|
@@ -565,10 +659,8 @@ export async function readLastRequestModel(sessionId, root = null) {
|
|
|
565
659
|
let lastRequest = null;
|
|
566
660
|
let lastSelection = null;
|
|
567
661
|
const PENDING_LIMIT = 8 * 1024 * 1024; // 单行缓冲上限:超过即视为异常,避免无上限增长
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
let event;
|
|
571
|
-
try { event = JSON.parse(line); } catch { return; }
|
|
662
|
+
// 事件级处理(行级与全量兜底共用):只记「最后一次」请求头与选择意图
|
|
663
|
+
const consumeEvent = (event) => {
|
|
572
664
|
const hit = modelEventOf(event);
|
|
573
665
|
if (hit === null) return;
|
|
574
666
|
if (hit.kind === "request") {
|
|
@@ -578,6 +670,12 @@ export async function readLastRequestModel(sessionId, root = null) {
|
|
|
578
670
|
lastSelection = hit.value;
|
|
579
671
|
}
|
|
580
672
|
};
|
|
673
|
+
const consume = (line) => {
|
|
674
|
+
if (!line) return;
|
|
675
|
+
let event;
|
|
676
|
+
try { event = JSON.parse(line); } catch { return; }
|
|
677
|
+
consumeEvent(event);
|
|
678
|
+
};
|
|
581
679
|
const onChunk = (chunk) => {
|
|
582
680
|
pending += decoder.write(chunk);
|
|
583
681
|
if (pending.length > PENDING_LIMIT) {
|
|
@@ -606,5 +704,11 @@ export async function readLastRequestModel(sessionId, root = null) {
|
|
|
606
704
|
}
|
|
607
705
|
pending += decoder.end();
|
|
608
706
|
consume(pending);
|
|
707
|
+
// 尾部窗口内没有任何模型事件:退回全量流式扫一遍(复用公共底座,常量内存),
|
|
708
|
+
// 避免把「跑过很多轮但最近几帧都是工具/正文」的会话误报成「从未运行过」。
|
|
709
|
+
if (lastRequest === null && lastSelection === null) {
|
|
710
|
+
const { streamSessionEvents } = await import("../../sessions/log-stream.js");
|
|
711
|
+
await streamSessionEvents(buf, consumeEvent);
|
|
712
|
+
}
|
|
609
713
|
return { lastRequest, lastSelection, scanned };
|
|
610
714
|
}
|
|
@@ -98,7 +98,8 @@ async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
|
|
|
98
98
|
parameters: {
|
|
99
99
|
target: { type: "string", description: "目标成员:成员名、sessionId 或标题关键字。留空 = 全部成员(排除 Lead)" },
|
|
100
100
|
provider: { type: "string", description: "provider 分组名(list_models 的顶层分组)。留空 = 跟随主代理的 provider" },
|
|
101
|
-
model: { type: "string", description: "该 provider 下的模型 id(含前缀)。留空 = 跟随主代理的 model" }
|
|
101
|
+
model: { type: "string", description: "该 provider 下的模型 id(含前缀)。留空 = 跟随主代理的 model" },
|
|
102
|
+
override: { type: "boolean", description: "可选。true = 额外写持久 override(每轮强制改写请求,旧行为;用于官方 selection 链路不生效的场景)。默认 false = 只写 model/selection 事件,走官方链路(UI 切换不被压制)" }
|
|
102
103
|
},
|
|
103
104
|
output: {
|
|
104
105
|
schema: { type: "string" },
|
|
@@ -124,10 +125,11 @@ async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
|
|
|
124
125
|
const pair = await validateModelPair(ctx, provider, model);
|
|
125
126
|
if (pair.error) return `切换失败:${pair.error}`;
|
|
126
127
|
// target 留空 = 作用于全部成员;与聊天指令 /model-switch 走同一套目标解析
|
|
128
|
+
const wantOverride = args?.override === true;
|
|
127
129
|
const resolved = resolveTargets(ctx, args?.target, { caller });
|
|
128
130
|
if (resolved.error) return `切换失败:${resolved.error}`;
|
|
129
131
|
if (resolved.all) {
|
|
130
|
-
const report = await applyModelToMany(ctx, resolved.targets, { provider, model });
|
|
132
|
+
const report = await applyModelToMany(ctx, resolved.targets, { provider, model, override: wantOverride });
|
|
131
133
|
const extra = [];
|
|
132
134
|
if (followedLead) {
|
|
133
135
|
extra.push(`模型来源:跟随主代理 ${followedLead.sessionId}(${followedLead.modelSource})`);
|
|
@@ -137,7 +139,7 @@ async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
|
|
|
137
139
|
return renderBatchReport(`批量切换模型 → ${provider}/${model}`, report, extra);
|
|
138
140
|
}
|
|
139
141
|
const found = resolved.targets[0];
|
|
140
|
-
const switchResult = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent);
|
|
142
|
+
const switchResult = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent, { override: wantOverride });
|
|
141
143
|
if (switchResult.error) return `切换失败:${switchResult.error}`;
|
|
142
144
|
const memberLabel = found.member?.name ? `${found.member.name}(${found.sessionId})` : found.sessionId;
|
|
143
145
|
// 查证(注册 job,不阻塞):登记待查证项 → 该成员下次发起请求时由 agent/request 事件判定
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
//
|
|
19
19
|
// 【失败即静默】日志缺失/损坏、用量读不到、首回合无上次用量 —— 一律返回 null,绝不阻断回合。
|
|
20
20
|
import { readRecentUserTurns } from "../../sessions/events.js";
|
|
21
|
+
// 会话目标解析的公共底座(id / 前缀 / 标题 / 成员名;与成员轨迹共用)
|
|
22
|
+
import { resolveTargetSession } from "../../sessions/resolve.js";
|
|
21
23
|
import { log } from "../../shared/log.js";
|
|
22
24
|
import { cfg } from "../../core/config.js";
|
|
23
25
|
import { writeJsonAtomicSync, readJsonSafeSync } from "../../shared/json-store.js";
|
|
@@ -329,40 +331,12 @@ export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
|
|
|
329
331
|
* @returns {Promise<{id:string,title?:string|null}|{candidates:Array<{id:string,title?:string|null}>}|null>}
|
|
330
332
|
*/
|
|
331
333
|
export async function resolveSessionTarget(ctx, want, selfId) {
|
|
332
|
-
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
};
|
|
341
|
-
if (root && await isDirectory(root)) {
|
|
342
|
-
for (const proj of await readdir(root)) {
|
|
343
|
-
const pp = join(root, proj);
|
|
344
|
-
if (!(await isDirectory(pp))) continue;
|
|
345
|
-
for (const dir of await readdir(pp)) {
|
|
346
|
-
if (!(await isDirectory(join(pp, dir)))) continue;
|
|
347
|
-
if (dir.startsWith("session-") || dir === selfId) ids.push(dir);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
if (ids.includes(want)) return { id: want };
|
|
352
|
-
const byPrefix = ids.filter((id) => id.startsWith(want));
|
|
353
|
-
if (byPrefix.length === 1) return { id: byPrefix[0] };
|
|
354
|
-
if (byPrefix.length > 1) return { candidates: byPrefix.map((id) => ({ id })) };
|
|
355
|
-
// 标题子串:只在落盘索引里找(避免为了解析一个参数去逐份读日志)
|
|
356
|
-
try {
|
|
357
|
-
const { loadListDiskCache } = await import("../../sessions/list-cache.js");
|
|
358
|
-
let entries = {};
|
|
359
|
-
try { entries = loadListDiskCache(ctx)?.entries ?? {}; } catch { entries = {}; }
|
|
360
|
-
const lower = want.toLowerCase();
|
|
361
|
-
const byTitle = Object.entries(entries)
|
|
362
|
-
.filter(([, e]) => String(e?.title ?? "").toLowerCase().includes(lower))
|
|
363
|
-
.map(([id, e]) => ({ id, title: e?.title ?? null }));
|
|
364
|
-
if (byTitle.length === 1) return byTitle[0];
|
|
365
|
-
if (byTitle.length > 1) return { candidates: byTitle };
|
|
366
|
-
} catch { /* 索引不可用:退化为只按 id 匹配 */ }
|
|
367
|
-
return null;
|
|
334
|
+
// 实现已抽到公共底座 sessions/resolve.js(成员轨迹与记忆取回共用同一套解析)。
|
|
335
|
+
//
|
|
336
|
+
// 旧实现在这里用 `dir.startsWith("session-")` 判断会话目录,而子代理/成员会话的目录名是
|
|
337
|
+
// **裸 UUID**(不带 session- 前缀),导致那些会话永远进不了候选集 —— 完整 id 与前缀都解析不出来。
|
|
338
|
+
// 公共底座改为「目录里有会话日志文件」作为判据,与目录命名规则解耦。
|
|
339
|
+
//
|
|
340
|
+
// 顺带获得成员名支持:`recall_memory({ session: "writer" })` 这类写法现在也能解析。
|
|
341
|
+
return await resolveTargetSession(ctx, want, selfId);
|
|
368
342
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* 其余(会话操作实现、标题工具、日志)在本模块内直接 import,不再依赖 apply 的作用域。
|
|
8
8
|
*/
|
|
9
9
|
import { log } from '../shared/log.js';
|
|
10
|
+
import { restoreSessionsFromExport } from '../sessions/export-restore.js';
|
|
10
11
|
import { MSG_NEED_SESSION_ID } from '../shared/constants.js';
|
|
11
12
|
import { titleString, foldTitle, workspaceNameOf, archiveTitleWithWs, stripArchiveWsPrefix } from './rename/title.js';
|
|
12
13
|
import { unarchiveSession, deleteSession, undoLastMessage, deleteBatchSessions, deleteByRule, emptyTrash, purgeExpiredTrash } from './session-ops.js';
|
|
@@ -107,6 +108,26 @@ export function registerSessionOpsRoutes({ webServer, ctx, send, readJson, inval
|
|
|
107
108
|
}
|
|
108
109
|
};
|
|
109
110
|
|
|
111
|
+
// ---------- POST /api/session-conductor/restore-from-export ----------
|
|
112
|
+
// 把「会话导出包」(解压后的明文 jsonl 目录)覆盖回运行时格式。
|
|
113
|
+
// 为什么不能直接拷:导出包是明文 session.v4.jsonl,而运行时读的是带校验和的多帧
|
|
114
|
+
// session.v4.jsonl.zstd;直接拷会被宿主判「总和检查码不匹配」或 corrupt。
|
|
115
|
+
// 默认 dryRun(只报告会覆盖哪些、事件多少),body.dryRun=false 才写盘。
|
|
116
|
+
route("/api/session-conductor/restore-from-export", "restore-from-export-failed", async (body, res) => {
|
|
117
|
+
const exportDir = typeof body?.exportDir === "string" ? body.exportDir : "";
|
|
118
|
+
if (exportDir === "") {
|
|
119
|
+
return send(res, 400, { ok: false, error: { code: "bad-request", message: "需要 exportDir(解压后的导出包目录绝对路径)" } });
|
|
120
|
+
}
|
|
121
|
+
const result = await restoreSessionsFromExport({
|
|
122
|
+
exportDir,
|
|
123
|
+
sessionIds: Array.isArray(body?.sessionIds) ? body.sessionIds : undefined,
|
|
124
|
+
dryRun: body?.dryRun !== false,
|
|
125
|
+
eventsPerFrame: typeof body?.eventsPerFrame === "number" ? body.eventsPerFrame : undefined,
|
|
126
|
+
ctx,
|
|
127
|
+
});
|
|
128
|
+
send(res, result.ok ? 200 : 400, result);
|
|
129
|
+
}, { requireSessionId: false });
|
|
130
|
+
|
|
110
131
|
// ---------- POST /api/session-conductor/archive ----------
|
|
111
132
|
// 归档时给标题加「[工作区名] 」前缀(数据层带前缀,面板显示剥离,已归档视图按前缀分组)。
|
|
112
133
|
route("/api/session-conductor/archive", "archive-failed", async (body, res) => {
|