dsh-session-conductor 1.3.17 → 1.3.18

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 CHANGED
@@ -225,6 +225,54 @@ preview { action: "serve", file: "<插件根>/assets/preview.html", backend: "h
225
225
  注意:正在跑回合的成员会被跳过(等 60s 仍未结束则报错并提示先中断)—— 这是有意的,
226
226
  运行中切模型不生效,还可能被其内存态覆盖。
227
227
 
228
+ ### 为什么子代理界面上没有「切换模型」按钮
229
+
230
+ 主会话输入框右上角那个模型选择器,在**子代理会话里是宿主硬性关掉的**,不是本插件漏做,也不是宿主漏做:
231
+
232
+ ```js
233
+ // client/ui-model-selection/src/client/index.ts:134
234
+ available: session => sessions.subagentAddress(session.sessionId) === undefined,
235
+
236
+ // client/ui-model-selection/src/client/directory.ts:153
237
+ throw new Error('model selection is unavailable for addressed subagent sessions')
238
+ ```
239
+
240
+ 宿主源码给出的原因(同文件 `index.ts:9-11`):子代理会话绑定的模型 RPC 会**绕过父级续跑路径去激活已持久化的历史**,
241
+ 所以 UI 层把入口直接关掉。任何走 `session.selectModel` 那条 RPC 的实现都会撞上同一个拦截,装插件也绕不过去。
242
+
243
+ **所以子代理的模型只能由本插件切换** —— 我们写的是 `model/selection` 事件(交给宿主官方投影接管),
244
+ 不经过那条被禁的 RPC,因此不受该限制:
245
+
246
+ | 目的 | 用户指令 | AI 工具 |
247
+ |---|---|---|
248
+ | 单个成员换成指定模型 | `/model-switch <成员名> <provider>/<模型>` | `set_member_model({ target, provider, model })` |
249
+ | 单个成员跟我用同一个 | `/model-switch <成员名> auto` | `set_member_model({ target })` |
250
+ | 全部子代理跟我用同一个 | `/model-switch * auto` | `set_member_model({})` |
251
+
252
+ 权限预设同理(`/perm-switch`、`set_member_permission`)—— 子代理界面上同样没有权限切换入口。
253
+
254
+ ### 模型切换为什么要写 override(以及它和官方界面的关系)
255
+
256
+ 官方模型选择链是「投影 pending → 会话请求头 → 部署默认」:写 `model/selection` 事件只让 pending
257
+ 生效**一轮**,被 `request/header` 消费后,新回合会沿请求头链回落,最终回到部署默认模型。
258
+ 所以本插件在 `agent/request` 瀑布流里按会话强制改写请求配置(每轮都改 → 跨轮必然持久),
259
+ 这份持久状态存在插件域 `memberModelOverrides` 里。
260
+
261
+ **这个机制曾有致命副作用**(1.3.18 修复):override 优先级高于官方链路,一旦写入,
262
+ 用户在主会话界面用官方模型选择器切换就**完全无效** —— 界面显示新模型、请求一直走 override 里的旧模型,
263
+ 不报错、不提示、也没有清除入口。现场表现为「界面切三次、跨三个回合、改日志、重启宿主全都不动」。
264
+
265
+ **现在的行为**:官方切换会往 `modelSelection` 投影写 `pending`,插件每轮请求前把它与 override 比对,
266
+ 不一致就**把 override 同步成官方的新值**(同步而非清除 —— 既让界面选择立刻生效,又保留跨轮持久能力)。
267
+ 同步失败只记一条日志并退回旧 override,绝不打断请求。
268
+
269
+ 使用注意:
270
+
271
+ - 同步只发生在**已有 override 的会话**上;没有 override 的会话完全交给官方链路,插件不干预。
272
+ - `set_member_model` 的批量「跟随主代理」(`auto` / 三参数全空)写入的是**当时**主代理用的模型;
273
+ 之后主代理再换模型,这些成员**不会**自动跟着变(需再执行一次)。
274
+ - 想让某个会话彻底回到「官方说了算」,执行一次官方界面切换即可(override 会被同步成新值)。
275
+
228
276
  「最近请求」取自**进程内真实请求记录**(瞬时、不扫日志);显示「未知」说明本进程还没见它请求过,用 ② 查历史。若同时显示目标与最近请求且不一致,会附 `⚠️ 与目标不一致(下一回合生效前属正常)`。
229
277
 
230
278
  **② 查某个成员实际在用什么**
package/lib/apply.js CHANGED
@@ -21,7 +21,8 @@ import { refreshTitleState } from './features/rename/title.js';
21
21
  // 成员模型:本文件用到 findTargetAgent / switchAgentModel / getMemberModelOverride /
22
22
  // applyModelOverride / validateModelPair(拆出 apply.js 时漏了这一行 import,
23
23
  // 运行到相关代码路径会 ReferenceError: getMemberModelOverride is not defined)
24
- import { getMemberModelOverride, applyModelOverride, noteRequestModel, takeSwitchOutcomes } from './features/member-model/index.js';
24
+ import { getMemberModelOverride, applyModelOverride, noteRequestModel, takeSwitchOutcomes,
25
+ adoptOfficialSelection, readOfficialPendingSelection, setMemberModelOverride } from './features/member-model/index.js';
25
26
  // 会话操作的路由注册已抽到独立模块(handler 实现见该模块;此处只留注册入口)
26
27
  import { registerSessionOpsRoutes } from './features/session-ops-routes.js';
27
28
  import { registerMemberModelTools } from './features/member-model/tools.js';
@@ -317,7 +318,25 @@ export async function apply(ctx, config = {}) {
317
318
  // override 由 set_member_model 写入(domain 持久 + 内存缓存镜像),每轮都改 → 跨轮持久。
318
319
  ctx.on("agent/request", async ({ agent }, next) => {
319
320
  const resolved = await next();
320
- const override = await getMemberModelOverride(ctx, agent?.session?.id);
321
+ const sessionId = agent?.session?.id;
322
+ let override = await getMemberModelOverride(ctx, sessionId);
323
+ // 官方界面切换同步:override 优先级高于官方链路,若用户在主会话界面用官方选择器改了模型,
324
+ // 不把新值同步进 override 就会出现「界面显示新模型、请求一直走旧模型」且无处可查
325
+ // (实测事故:批量写入的 override 把会话钉在已失效 provider 上,界面切换、改日志、重启全无效)。
326
+ // 做法是同步而不是清除:官方切换会写投影 pending,这里把它写回 override,
327
+ // 既让界面选择立刻生效,又保留 override 的跨轮持久能力(不会回落部署默认)。
328
+ try {
329
+ const pending = readOfficialPendingSelection(ctx, agent);
330
+ const adopt = adoptOfficialSelection(override, pending);
331
+ if (adopt) {
332
+ await setMemberModelOverride(ctx, sessionId, adopt.provider, adopt.model);
333
+ override = adopt;
334
+ log(ctx, `官方界面切换已同步到 override:${sessionId} → ${adopt.provider}/${adopt.model}`);
335
+ }
336
+ } catch (error) {
337
+ // 同步失败绝不打断请求:退回用旧 override(行为与修复前一致)
338
+ log(ctx, `官方切换同步失败(不影响本次请求): ${String(error?.message ?? error)}`);
339
+ }
321
340
  const applied = applyModelOverride(resolved, override);
322
341
  // 切换查证(事件驱动):记录本次请求**真实使用**的 provider/model;若该会话有待查证项,
323
342
  // 就在这里按事件判定结果并入队(切换本身不阻塞、不轮询 —— 只等事件到)。
@@ -13,7 +13,11 @@ export {
13
13
  // 批量目标解析(空 / `*` / `all` = 全部成员,排除 Lead)与 Lead 定位
14
14
  collectTeamMembers, isAllTarget, resolveTargets, resolveLeader,
15
15
  } from "./locate.js";
16
- export { getMemberModelOverride, setMemberModelOverride, applyModelOverride, resetMemberModelCacheForTest } from "./override.js";
16
+ export {
17
+ getMemberModelOverride, setMemberModelOverride, applyModelOverride, resetMemberModelCacheForTest,
18
+ // 官方界面切换的同步:override 优先级高于官方链路,不同步就会出现「界面切了但请求不变」
19
+ adoptOfficialSelection, readOfficialPendingSelection,
20
+ } from "./override.js";
17
21
  export { waitForIdle, appendSelectionEventToLog, switchAgentModel } from "./switch.js";
18
22
  // 切换查证(事件驱动,注册 job 不阻塞):登记待查证项 → 每次请求记录实际模型 → 按事件判定 → 结果回报
19
23
  export {
@@ -77,6 +77,56 @@ export function applyModelOverride(resolved, override) {
77
77
  return { ...rest, provider: override.provider, model: override.model };
78
78
  }
79
79
 
80
+ /**
81
+ * 判断是否要把「官方界面刚选的模型」同步进 override。
82
+ *
83
+ * 【为什么需要它】本插件的 override 是**每轮强制改写请求**的持久状态,优先级高于官方链路。
84
+ * 于是有个致命副作用:一旦写入,用户在主会话界面用官方模型选择器切换**完全无效** ——
85
+ * 界面显示新模型、实际请求走 override 里的旧模型,而且不报错、不提示、无清除入口
86
+ * (实测事故:批量「跟随主代理」把一批会话钉在某个已失效 provider 上,界面怎么切都不动,
87
+ * 改会话日志、重启宿主也都没用,因为 override 存在 domain 里且每轮覆盖)。
88
+ *
89
+ * 【做法:同步,而不是清除】官方切换会往 `modelSelection` 投影写 `pending`;
90
+ * 而插件在 hook 里用 override 改写了请求配置 → 与 pending 不匹配 → `consumeSelection` 不会
91
+ * 清空它,于是 pending 会一直保留。每轮把它与 override 比对,不一致就**把 override 更新成
92
+ * pending 的值**:官方界面选择立刻生效,同时仍享有 override 的「跨轮持久」能力(不会回落)。
93
+ *
94
+ * 只在**已存在 override** 的会话上同步:没有 override 的会话不干预,交给官方链路自己跑。
95
+ *
96
+ * @param {{provider: string, model: string} | null} override 当前生效的 override
97
+ * @param {{provider: string, model: string} | null} pending 官方投影里的待生效选择
98
+ * @returns {{provider: string, model: string} | null} 需要写入的新 override;null = 无需同步
99
+ */
100
+ export function adoptOfficialSelection(override, pending) {
101
+ if (!override || !pending) return null;
102
+ if (typeof pending.provider !== "string" || typeof pending.model !== "string") return null;
103
+ if (pending.provider === "" || pending.model === "") return null;
104
+ if (override.provider === pending.provider && override.model === pending.model) return null;
105
+ return { provider: pending.provider, model: pending.model };
106
+ }
107
+
108
+ /**
109
+ * 从会话投影里读官方「待生效」的模型选择(官方界面切换会写这里)。
110
+ *
111
+ * 投影不可用、字段缺失、调用抛错一律返回 null(宁可不同步,也不要在 hook 里抛异常打断请求)。
112
+ *
113
+ * @param {object} ctx 插件 context
114
+ * @param {object} agent 当前请求所属 agent
115
+ * @returns {{provider: string, model: string} | null}
116
+ */
117
+ export function readOfficialPendingSelection(ctx, agent) {
118
+ try {
119
+ const projections = ctx?.sessionProjections ?? ctx?.get?.("sessionProjections");
120
+ const state = projections?.stateOf?.(agent?.session, "modelSelection");
121
+ const pending = state?.pending;
122
+ if (!pending || typeof pending !== "object") return null;
123
+ if (typeof pending.provider !== "string" || typeof pending.model !== "string") return null;
124
+ return { provider: pending.provider, model: pending.model };
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
80
130
  /** 测试钩子:清空 override 缓存(单测在场景之间调用)。 */
81
131
  export function resetMemberModelCacheForTest() {
82
132
  memberModelCache.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-conductor",
3
- "version": "1.3.17",
3
+ "version": "1.3.18",
4
4
  "description": "DSH session management enhancement: archive/restore sessions, batch & conditional delete, AI auto-rename, interrupted-session auto-resume, release idle sessions, workspace grouping, undo last message, repair damaged session logs, value analysis, full-text search, per-member model switching (list_models / set_member_model), and plan/closing template injection (system-prompt + context).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",