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
package/README.md
CHANGED
|
@@ -86,7 +86,9 @@ send_to_session({ sessionId, text, mode, dryRun }) // sessionId/text 必填;mo
|
|
|
86
86
|
recall_memory({ rounds, session }) // 都可空:rounds 默认取配置、上限 50;session 空=自己
|
|
87
87
|
context_usage({}) // 无参
|
|
88
88
|
read_member_trace({ session, rounds, detail, include, maxCharsPerItem })
|
|
89
|
-
// session
|
|
89
|
+
// session 空=自己(可填成员名/id/前缀/标题);rounds 默认 1(上限 20)
|
|
90
|
+
// detail=summary(每条一行)|full(完整文本);include 可裁 reasoning,text,tool
|
|
91
|
+
// maxCharsPerItem 单条截断(默认 800)
|
|
90
92
|
```
|
|
91
93
|
|
|
92
94
|
典型调用:
|
|
@@ -103,6 +105,15 @@ clone_member_as({ source: "probe1", name: "rename1" })
|
|
|
103
105
|
|
|
104
106
|
// 给另一个会话发消息(dryRun 先预览目标状态与限流余量)
|
|
105
107
|
send_to_session({ sessionId: "session-xxxx", text: "继续刚才的任务", dryRun: true })
|
|
108
|
+
|
|
109
|
+
// 看子代理最近一轮的执行过程(子代理只有最后一条回复会被自动发回,中间的思考靠这里读)
|
|
110
|
+
read_member_trace({ session: "writer" })
|
|
111
|
+
|
|
112
|
+
// 看最近 3 轮,只要思考与工具调用
|
|
113
|
+
read_member_trace({ session: "writer", rounds: 3, include: "reasoning,tool" })
|
|
114
|
+
|
|
115
|
+
// 要完整文本(默认 summary 每条压成一行)
|
|
116
|
+
read_member_trace({ session: "writer", detail: "full" })
|
|
106
117
|
```
|
|
107
118
|
|
|
108
119
|
## API
|
package/lib/apply.js
CHANGED
|
@@ -317,24 +317,38 @@ export async function apply(ctx, config = {}) {
|
|
|
317
317
|
// 不依赖官方「投影 pending + 请求头链」(那条链在新回合会回落部署默认模型),
|
|
318
318
|
// override 由 set_member_model 写入(domain 持久 + 内存缓存镜像),每轮都改 → 跨轮持久。
|
|
319
319
|
ctx.on("agent/request", async ({ agent }, next) => {
|
|
320
|
-
const resolved = await next();
|
|
321
320
|
const sessionId = agent?.session?.id;
|
|
321
|
+
// ① 进入即读 override 与官方 pending —— 这是 UI 切换「立即生效」的关键。
|
|
322
|
+
// 旧实现把同步放在 await next() 之后:请求组装阶段官方链路可能解析出旧模型
|
|
323
|
+
// (system-prompt/assemble 被缓存时 selection.assembled 停留旧值),返回阶段我们
|
|
324
|
+
// 再拿旧 override 一盖,本次请求就定型了;残留的 pending 要等下一轮才被读到,
|
|
325
|
+
// 表现为「UI 切了模型,AI 用旧模型回完一轮才切过去」。pending 只会被请求后的
|
|
326
|
+
// consumeSelection 清掉,waterfall 进入时它必然还在 —— 所以进入时同步最可靠。
|
|
322
327
|
let override = await getMemberModelOverride(ctx, sessionId);
|
|
323
|
-
// 官方界面切换同步:override 优先级高于官方链路,若用户在主会话界面用官方选择器改了模型,
|
|
324
|
-
// 不把新值同步进 override 就会出现「界面显示新模型、请求一直走旧模型」且无处可查
|
|
325
|
-
// (实测事故:批量写入的 override 把会话钉在已失效 provider 上,界面切换、改日志、重启全无效)。
|
|
326
|
-
// 做法是同步而不是清除:官方切换会写投影 pending,这里把它写回 override,
|
|
327
|
-
// 既让界面选择立刻生效,又保留 override 的跨轮持久能力(不会回落部署默认)。
|
|
328
328
|
try {
|
|
329
329
|
const pending = readOfficialPendingSelection(ctx, agent);
|
|
330
330
|
const adopt = adoptOfficialSelection(override, pending);
|
|
331
331
|
if (adopt) {
|
|
332
332
|
await setMemberModelOverride(ctx, sessionId, adopt.provider, adopt.model);
|
|
333
333
|
override = adopt;
|
|
334
|
-
log(ctx, `官方界面切换已同步到 override
|
|
334
|
+
log(ctx, `官方界面切换已同步到 override(请求前):${sessionId} → ${adopt.provider}/${adopt.model}`);
|
|
335
|
+
}
|
|
336
|
+
} catch (error) {
|
|
337
|
+
// 同步失败绝不打断请求:退回用旧 override
|
|
338
|
+
log(ctx, `官方切换同步失败(不影响本次请求): ${String(error?.message ?? error)}`);
|
|
339
|
+
}
|
|
340
|
+
const resolved = await next();
|
|
341
|
+
// ② 返回阶段兜底:极端情况下进入时投影尚未 fold 到最新(刚 append 事件还没被折叠),
|
|
342
|
+
// 返回阶段再给一次机会;正常路径这里 pending 与 override 已一致,不会再写。
|
|
343
|
+
try {
|
|
344
|
+
const pending = readOfficialPendingSelection(ctx, agent);
|
|
345
|
+
const adopt = adoptOfficialSelection(override, pending);
|
|
346
|
+
if (adopt) {
|
|
347
|
+
await setMemberModelOverride(ctx, sessionId, adopt.provider, adopt.model);
|
|
348
|
+
override = adopt;
|
|
349
|
+
log(ctx, `官方界面切换已同步到 override(返回兜底):${sessionId} → ${adopt.provider}/${adopt.model}`);
|
|
335
350
|
}
|
|
336
351
|
} catch (error) {
|
|
337
|
-
// 同步失败绝不打断请求:退回用旧 override(行为与修复前一致)
|
|
338
352
|
log(ctx, `官方切换同步失败(不影响本次请求): ${String(error?.message ?? error)}`);
|
|
339
353
|
}
|
|
340
354
|
const applied = applyModelOverride(resolved, override);
|
package/lib/client.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
function __dshScLoad(registration) {
|
|
2
|
+
const host = window.__ModuleLoader__;
|
|
3
|
+
if (registration.id === "dsh-session-conductor") { host.load(registration); return; }
|
|
4
|
+
const cycle = (window.__dshScCycle = (window.__dshScCycle ?? 0) + (window.__dshScCycleOpen ? 0 : 1));
|
|
5
|
+
window.__dshScCycleOpen = true;
|
|
6
|
+
const seen = (window.__dshScSeen ??= new Set());
|
|
7
|
+
const id = `${registration.id}@${cycle}`;
|
|
8
|
+
if (seen.has(id)) return;
|
|
9
|
+
seen.add(id);
|
|
10
|
+
host.load({ ...registration, id });
|
|
11
|
+
}
|
|
12
|
+
window.__dshScCycleOpen = false;
|
|
1
13
|
// dsh-session-conductor —— 会话管理增强插件(浏览器半边)。
|
|
2
14
|
// dsh-skip-quality(conv-log:fallback 行为 UI 文案内嵌 JSON,非文档注释,属规则误报)
|
|
3
15
|
//
|
|
@@ -72,7 +84,7 @@ window.__SC_TR__ = (key, vars) => {
|
|
|
72
84
|
return String(val);
|
|
73
85
|
};
|
|
74
86
|
|
|
75
|
-
|
|
87
|
+
__dshScLoad({
|
|
76
88
|
id: "dsh-session-conductor",
|
|
77
89
|
factory: (require) => {
|
|
78
90
|
var module = { exports: {} };
|
|
@@ -2407,7 +2419,7 @@ window.__ModuleLoader__.load({
|
|
|
2407
2419
|
return module.exports;
|
|
2408
2420
|
},
|
|
2409
2421
|
});
|
|
2410
|
-
|
|
2422
|
+
__dshScLoad({
|
|
2411
2423
|
id: "dsh-session-conductor-group",
|
|
2412
2424
|
factory: (require) => {
|
|
2413
2425
|
var module = { exports: {} };
|
|
@@ -2522,7 +2534,7 @@ window.__ModuleLoader__.load({
|
|
|
2522
2534
|
* 排队消息列表仍由原生 dock 负责(不重复渲染)。不改 DSH 原生源码。
|
|
2523
2535
|
*/
|
|
2524
2536
|
|
|
2525
|
-
|
|
2537
|
+
__dshScLoad({
|
|
2526
2538
|
id: "dsh-session-conductor-processing",
|
|
2527
2539
|
factory: (require) => {
|
|
2528
2540
|
var module = { exports: {} };
|
|
@@ -2627,7 +2639,7 @@ window.__ModuleLoader__.load({
|
|
|
2627
2639
|
* 设置 → 插件配置 → 「压缩模型」:会话模型旁单独选压缩用模型(省 pro 成本)。
|
|
2628
2640
|
* 可选:跟随会话模型(默认)/ 指定便宜模型(agnes 等)。写入 /api/session-conductor/compaction-model。
|
|
2629
2641
|
*/
|
|
2630
|
-
|
|
2642
|
+
__dshScLoad({
|
|
2631
2643
|
id: "dsh-session-conductor-compaction-model",
|
|
2632
2644
|
factory: (require) => {
|
|
2633
2645
|
var module = { exports: {} };
|
|
@@ -2751,7 +2763,7 @@ window.__ModuleLoader__.load({
|
|
|
2751
2763
|
* UI 组件 scs_* = 页面级。
|
|
2752
2764
|
*/
|
|
2753
2765
|
|
|
2754
|
-
|
|
2766
|
+
__dshScLoad({
|
|
2755
2767
|
id: "dsh-session-conductor-settings",
|
|
2756
2768
|
factory: (require) => {
|
|
2757
2769
|
var module = { exports: {} };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// dsh-session-conductor — 存储域不可用时的文件后备(降级实现)
|
|
2
|
+
//
|
|
3
|
+
// 【为什么单独成文件】`core/domain.js` 顶层静态依赖宿主包 `@deepseek-ai/dsh-storage-domain`,
|
|
4
|
+
// 工作区单测没有宿主依赖,静态 import 会让整个模块在导入时就 ERR_MODULE_NOT_FOUND。
|
|
5
|
+
// 把降级逻辑放在这里(只依赖 node 内置 + 已抽出的 json-store),就能被单测直接覆盖。
|
|
6
|
+
//
|
|
7
|
+
// 【降级触发条件】storageDomain 域出现下面任一情况时,改读写域自己的落盘 JSON:
|
|
8
|
+
// · 服务拿不到(ctx.get("storageDomain") 为空)
|
|
9
|
+
// · 名字仍被占(already open)而查到的旧句柄已 closed 或停在 disposing
|
|
10
|
+
// 这两种情况下插件所有依赖持久化的功能本会一路 500,只能重启恢复。
|
|
11
|
+
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { readJsonSafeSync, writeJsonAtomicSync } from "../shared/json-store.js";
|
|
15
|
+
|
|
16
|
+
/** DSH 主目录(与 core/config.js 的 resolveDshHome 同规则;此处内联避免底层模块反向依赖上层配置)。 */
|
|
17
|
+
function dshHomePath() {
|
|
18
|
+
if (process.env.DSH_HOME) return process.env.DSH_HOME;
|
|
19
|
+
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
20
|
+
return home ? join(home, ".dsh") : "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 判定域句柄是否还能用。
|
|
25
|
+
*
|
|
26
|
+
* 【为什么还要看 disposing】宿主的 DomainImpl 在 close 一开始就置 `disposing = true`,
|
|
27
|
+
* 要等写链排空、存储单元关闭后才置 `closed = true`。如果关闭过程卡住(写链未排空),
|
|
28
|
+
* 句柄会长期停在 `disposing`:`closed` 仍是 false,但任何读写都会抛「domain is closed」。
|
|
29
|
+
* 只看 `closed` 会把它当成活域继续用,于是接口一路 500。
|
|
30
|
+
*/
|
|
31
|
+
export function isUsableDomain(domain) {
|
|
32
|
+
return Boolean(domain) && domain.closed !== true && domain.disposing !== true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 文件后备域:storageDomain 的域不可用(卡在关闭中 / 名字被占导致无法重新打开)时的降级实现。
|
|
37
|
+
*
|
|
38
|
+
* 【为什么要有它】域卡住时插件所有依赖持久化的功能(列表缓存、模板、各开关、成员模型 override)
|
|
39
|
+
* 会全部 500,只能靠重启宿主恢复。而这些状态本来就落盘在
|
|
40
|
+
* `<DSH_HOME>/storages/dsh_session_conductor.json`,直接读写同一份文件即可继续工作。
|
|
41
|
+
*
|
|
42
|
+
* 【只实现 global.get / global.set】全插件对域的使用面只有这两者(外加 close),没有用到 tables,
|
|
43
|
+
* 因此后备实现只管 global 一节,其余字段(unit / tables)原样保留,保证宿主下次启动能正常加载。
|
|
44
|
+
*
|
|
45
|
+
* 【串行写】域本身有写链保证串行;文件后备没有,这里用一条 Promise 链模拟,
|
|
46
|
+
* 避免并发 `global.set` 互相覆盖(读-改-写之间的丢更新)。
|
|
47
|
+
*/
|
|
48
|
+
export function fileFallbackDomain() {
|
|
49
|
+
const home = dshHomePath();
|
|
50
|
+
const file = home ? join(home, "storages", "dsh_session_conductor.json") : "";
|
|
51
|
+
const readAll = () => {
|
|
52
|
+
if (!file) return {};
|
|
53
|
+
try { return readJsonSafeSync(file) ?? {}; } catch { return {}; }
|
|
54
|
+
};
|
|
55
|
+
let chain = Promise.resolve();
|
|
56
|
+
return {
|
|
57
|
+
// 供 isUsableDomain 判断:后备域永远可用
|
|
58
|
+
closed: false,
|
|
59
|
+
disposing: false,
|
|
60
|
+
__fallback: true,
|
|
61
|
+
global: {
|
|
62
|
+
get() {
|
|
63
|
+
return readAll().global ?? {};
|
|
64
|
+
},
|
|
65
|
+
set(value) {
|
|
66
|
+
chain = chain.then(async () => {
|
|
67
|
+
if (!file) throw new Error("DSH_HOME 未知,无法写入后备存储");
|
|
68
|
+
const current = readAll();
|
|
69
|
+
// 保留 unit / tables 原值:它们由宿主维护,后备路径不改动
|
|
70
|
+
const next = { ...current, global: value, tables: current.tables ?? {} };
|
|
71
|
+
if (current.unit !== undefined) next.unit = current.unit;
|
|
72
|
+
await writeJsonAtomicSync(file, next);
|
|
73
|
+
});
|
|
74
|
+
return chain;
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
close() { /* 后备域无需关闭 */ },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 降级只提示一次,避免每个请求都刷一行日志。 */
|
|
82
|
+
let fallbackLogged = false;
|
|
83
|
+
export function logOnceFallback(error, reused) {
|
|
84
|
+
if (fallbackLogged) return;
|
|
85
|
+
fallbackLogged = true;
|
|
86
|
+
try {
|
|
87
|
+
console.warn(
|
|
88
|
+
"[session-conductor] 存储域不可用,已降级为直接读写 storages/dsh_session_conductor.json:"
|
|
89
|
+
+ `open 失败=${String(error?.message ?? error)}`
|
|
90
|
+
+ `${reused ? ";复用的句柄已关闭/关闭中" : ""}。重启实例可恢复正常域。`,
|
|
91
|
+
);
|
|
92
|
+
} catch { /* 日志失败不影响主流程 */ }
|
|
93
|
+
}
|
package/lib/core/domain.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import { defineDomain } from "@deepseek-ai/dsh-storage-domain";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
+
// 域降级为文件后备时用(与域落盘同一份 JSON,保持 unit/global/tables 结构)
|
|
10
|
+
// 域不可用时的文件后备(抽到独立文件,便于工作区单测直接覆盖)
|
|
11
|
+
import { isUsableDomain, fileFallbackDomain, logOnceFallback } from "./domain-fallback.js";
|
|
9
12
|
|
|
10
13
|
/** 插件自身的持久化域:自动重命名/自动续跑开关 + 记账,存于 <DSH_HOME>/storages/dsh-session-conductor.json。
|
|
11
14
|
* 版本保持 1:autoContinue 是纯增量字段(带 default),旧域文件可无损加载。 */
|
|
@@ -110,9 +113,33 @@ let domainLive = false;
|
|
|
110
113
|
* @returns {Promise<object>} 域对象;storageDomain 服务缺失时 reject
|
|
111
114
|
*/
|
|
112
115
|
export function pluginDomain(ctx) {
|
|
113
|
-
if (domainPromise
|
|
116
|
+
if (domainPromise !== null) {
|
|
117
|
+
return domainPromise.then((cached) => {
|
|
118
|
+
if (isUsableDomain(cached)) return cached;
|
|
119
|
+
// 缓存里的句柄已不可用(关闭中 / 已关闭):丢掉重建
|
|
120
|
+
domainPromise = null;
|
|
121
|
+
domainLive = false;
|
|
122
|
+
if (cached && cached.__fallback) {
|
|
123
|
+
// 后备域本身可用,上面的 isUsableDomain 已放行;走到这里说明它被误标,直接重取
|
|
124
|
+
domainPromise = Promise.resolve(fileFallbackDomain());
|
|
125
|
+
return domainPromise;
|
|
126
|
+
}
|
|
127
|
+
return pluginDomain(ctx);
|
|
128
|
+
}, () => {
|
|
129
|
+
// 上次 open 失败:清掉缓存让它重新尝试(rejected Promise 不能被永久缓存)
|
|
130
|
+
domainPromise = null;
|
|
131
|
+
domainLive = false;
|
|
132
|
+
return pluginDomain(ctx);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
{
|
|
114
136
|
const storage = ctx.get("storageDomain");
|
|
115
|
-
if (!storage)
|
|
137
|
+
if (!storage) {
|
|
138
|
+
// 服务都拿不到:直接文件后备(报错只会让所有功能一起不可用)
|
|
139
|
+
domainLive = false;
|
|
140
|
+
domainPromise = Promise.resolve(fileFallbackDomain());
|
|
141
|
+
return domainPromise;
|
|
142
|
+
}
|
|
116
143
|
domainPromise = storage.open(pluginDomainSpec).then((domain) => {
|
|
117
144
|
domainLive = true;
|
|
118
145
|
attachDomainClose(ctx, domain);
|
|
@@ -120,22 +147,25 @@ export function pluginDomain(ctx) {
|
|
|
120
147
|
}).catch((error) => {
|
|
121
148
|
// 模块热重载场景:本实例的缓存是空的,但上一份实例打开的域仍被 storageDomain
|
|
122
149
|
// 按名字占用(facility 用 reserved 集合挡重复 open),于是再 open 必报 already-open。
|
|
123
|
-
// 宿主提供了按名字查已开域的口子,这里直接复用那份 handle
|
|
124
|
-
// 否则重载后列表/模板/开关会一路 500,直到整个实例重启为止。
|
|
150
|
+
// 宿主提供了按名字查已开域的口子,这里直接复用那份 handle。
|
|
125
151
|
const reused = isAlreadyOpenError(error) && typeof storage.get === "function"
|
|
126
152
|
? storage.get(pluginDomainSpec.name)
|
|
127
153
|
: undefined;
|
|
128
|
-
if (reused) {
|
|
154
|
+
if (isUsableDomain(reused)) {
|
|
129
155
|
domainLive = true;
|
|
130
156
|
attachDomainClose(ctx, reused);
|
|
131
157
|
return reused;
|
|
132
158
|
}
|
|
159
|
+
// 复用的句柄也不可用(典型:上一次 close 卡在 disposing)—— 此时既不能再 open
|
|
160
|
+
// (名字仍被占),也没有活句柄可用,只能降级到文件后备,让功能继续可用。
|
|
161
|
+
// 【注意】绝不能在这里递归调用 pluginDomain:它还会走 already-open 分支,形成死循环。
|
|
133
162
|
domainPromise = null;
|
|
134
163
|
domainLive = false;
|
|
135
|
-
|
|
164
|
+
logOnceFallback(error, reused);
|
|
165
|
+
return fileFallbackDomain();
|
|
136
166
|
});
|
|
167
|
+
return domainPromise;
|
|
137
168
|
}
|
|
138
|
-
return domainPromise;
|
|
139
169
|
}
|
|
140
170
|
|
|
141
171
|
/** 判定「这个名字已经被占用」的重复打开错误(宿主 DomainError 带 code,保底看文案)。 */
|
|
@@ -24,12 +24,12 @@ export function memberDisplay(member, sessionId) {
|
|
|
24
24
|
* @param {{provider: string, model: string}} route 目标路由
|
|
25
25
|
* @returns {Promise<{rows: Array<{sessionId: string, label: string, ok: boolean, detail: string}>, okCount: number, failCount: number}>}
|
|
26
26
|
*/
|
|
27
|
-
export async function applyModelToMany(ctx, targets, { provider, model }) {
|
|
27
|
+
export async function applyModelToMany(ctx, targets, { provider, model, override }) {
|
|
28
28
|
const rows = [];
|
|
29
29
|
for (const t of targets) {
|
|
30
30
|
const label = memberDisplay(t.member, t.sessionId);
|
|
31
31
|
try {
|
|
32
|
-
const result = await switchAgentModel(ctx, t.sessionId, provider, model, t.agent ?? null);
|
|
32
|
+
const result = await switchAgentModel(ctx, t.sessionId, provider, model, t.agent ?? null, { override });
|
|
33
33
|
if (result?.error) {
|
|
34
34
|
rows.push({ sessionId: t.sessionId, label, ok: false, detail: String(result.error) });
|
|
35
35
|
} else {
|
|
@@ -120,8 +120,8 @@ async function renderMemberDetail(found) {
|
|
|
120
120
|
* 二参:切换 + 回显查证快照(与 AI 工具 `set_member_model` 同一套落点:
|
|
121
121
|
* 写 override / 事件、登记查证任务、按请求级记录给判定)。
|
|
122
122
|
*/
|
|
123
|
-
async function switchMember(ctx, found, provider, model, invocation) {
|
|
124
|
-
const result = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent);
|
|
123
|
+
async function switchMember(ctx, found, provider, model, invocation, override = false) {
|
|
124
|
+
const result = await switchAgentModel(ctx, found.sessionId, provider, model, found.agent, { override });
|
|
125
125
|
if (result.error) return { kind: "error", text: `切换失败:${result.error}` };
|
|
126
126
|
// 登记查证任务:该成员下次发起请求时由 agent/request 事件判定,结果会注入发起方
|
|
127
127
|
try {
|
|
@@ -179,7 +179,9 @@ export async function handleModelSwitchCommand(ctx, invocation) {
|
|
|
179
179
|
return { kind: "success", text: await renderMemberDetail(found) };
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
//
|
|
182
|
+
// 二/三参:切换(单个或全部)。可选尾参 `override` = 旧行为(额外写每轮强制改写的
|
|
183
|
+
// override);默认不写 —— 官方 model/selection 链路当轮生效且持久,不压 UI 的选择。
|
|
184
|
+
const wantOverride = parts.some((x) => x.toLowerCase() === "override");
|
|
183
185
|
const routeRaw = parts[1];
|
|
184
186
|
let provider;
|
|
185
187
|
let model;
|
|
@@ -203,11 +205,11 @@ export async function handleModelSwitchCommand(ctx, invocation) {
|
|
|
203
205
|
|
|
204
206
|
// 单个目标:沿用原有路径(带查证快照与查证任务登记)
|
|
205
207
|
if (!resolved.all && resolved.targets.length === 1) {
|
|
206
|
-
return await switchMember(ctx, resolved.targets[0], provider, model, invocation);
|
|
208
|
+
return await switchMember(ctx, resolved.targets[0], provider, model, invocation, wantOverride);
|
|
207
209
|
}
|
|
208
210
|
|
|
209
211
|
// 全部目标:串行逐个切,回执必须带完整名单
|
|
210
|
-
const report = await applyModelToMany(ctx, resolved.targets, { provider, model });
|
|
212
|
+
const report = await applyModelToMany(ctx, resolved.targets, { provider, model, override: wantOverride });
|
|
211
213
|
const extra = [];
|
|
212
214
|
if (followedLead) {
|
|
213
215
|
extra.push(`模型来源:跟随主代理 ${followedLead.sessionId}(${followedLead.modelSource})`);
|
|
@@ -144,7 +144,9 @@ export async function createTeammateWithModel(ctx, exec, args) {
|
|
|
144
144
|
log(ctx, `创建成员 ${name}:中断后等待空闲 → ${JSON.stringify(idleState)}`);
|
|
145
145
|
|
|
146
146
|
// ③ 写 model/selection:与 set_member_model 完全同一条链路(默认不碰任何 @Remote)
|
|
147
|
-
|
|
147
|
+
// 建成员即指定模型:成员未挂载、冷恢复走 descriptor,selection 链路管不到首轮 ——
|
|
148
|
+
// 这里保持旧行为(写 override),与「创建时定模型」的工具语义一致。
|
|
149
|
+
const switchResult = await switchAgentModel(ctx, sessionId, modelProvider, model, null, { override: true });
|
|
148
150
|
log(ctx, `创建成员 ${name}(${sessionId})| 子代理provider=${subagentProvider} | 中断=${JSON.stringify(interrupt)} | 设模型 ${modelProvider}/${model}=${switchResult.error ? `失败 ${switchResult.error}` : "已写入"}`);
|
|
149
151
|
|
|
150
152
|
if (switchResult.error) {
|
|
@@ -63,6 +63,41 @@ export async function setMemberModelOverride(ctx, sessionId, provider, model) {
|
|
|
63
63
|
memberModelCache.set(sessionId, { provider, model });
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* 删除一个会话的 override(domain 与内存镜像同时清)。
|
|
68
|
+
*
|
|
69
|
+
* 【为什么需要】override 一旦存在,`agent/request` 就会**每轮**按它改写请求模型,
|
|
70
|
+
* 优先级压过官方 UI / `model/selection` 链路。因此「不带 override 参数」的切换
|
|
71
|
+
* 不能只是不写 —— 必须把存量条目删掉,官方链路才能接管;否则表现为
|
|
72
|
+
* 「切换成功但请求仍走旧模型」(selection 写入后,请求仍按 override 的值发出)。
|
|
73
|
+
*
|
|
74
|
+
* 幂等:不存在时返回 {removed:false},不报错。
|
|
75
|
+
*
|
|
76
|
+
* @param {object} ctx 插件 context
|
|
77
|
+
* @param {string} sessionId 会话 id
|
|
78
|
+
* @returns {Promise<{removed: boolean}>}
|
|
79
|
+
*/
|
|
80
|
+
export async function deleteMemberModelOverride(ctx, sessionId) {
|
|
81
|
+
if (!sessionId) return { removed: false };
|
|
82
|
+
const hadCache = memberModelCache.delete(sessionId);
|
|
83
|
+
let removedInDomain = false;
|
|
84
|
+
try {
|
|
85
|
+
const domain = await loadPluginDomain(ctx);
|
|
86
|
+
const state = domain.global.get() ?? {};
|
|
87
|
+
const overrides = { ...(state.memberModelOverrides ?? {}) };
|
|
88
|
+
if (Object.prototype.hasOwnProperty.call(overrides, sessionId)) {
|
|
89
|
+
delete overrides[sessionId];
|
|
90
|
+
await domain.global.set({ ...state, memberModelOverrides: overrides });
|
|
91
|
+
removedInDomain = true;
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// domain 不可用(如工作区自测环境缺宿主包):内存镜像已清,但落盘那条还在。
|
|
95
|
+
// 必须如实把原因带回调用方 —— 否则「切换成功、请求仍走旧模型」无从排查。
|
|
96
|
+
return { removed: hadCache, error: String(error?.message ?? error) };
|
|
97
|
+
}
|
|
98
|
+
return { removed: hadCache || removedInDomain };
|
|
99
|
+
}
|
|
100
|
+
|
|
66
101
|
/**
|
|
67
102
|
* 把成员模型 override 应用到一次请求配置上(`agent/request` hook 的核心逻辑,单独导出便于单测)。
|
|
68
103
|
* 丢掉继承的 reasoningEffort:换模型后应回到目标模型自己的 provider 默认行为。
|