dsh-session-conductor 1.3.10 → 1.3.11
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 +2 -1
- package/lib/apply.js +77 -14
- package/lib/client-parts/components/processing.js +18 -11
- package/lib/client.js +18 -11
- package/lib/core/config.js +42 -22
- package/lib/core/index-store.js +28 -23
- package/lib/eio-repair.js +2 -1
- package/lib/features/continue/eligibility.js +1 -1
- package/lib/features/member-model/command.js +33 -15
- package/lib/features/member-model/create-teammate.js +208 -0
- package/lib/features/member-model/locate.js +31 -17
- package/lib/features/member-model/switch.js +144 -65
- package/lib/features/memory-recall/index.js +39 -30
- package/lib/features/rename/analysis.js +25 -16
- package/lib/features/rename/title.js +26 -14
- package/lib/features/rename/value.js +38 -26
- package/lib/features/session-messaging/logic.js +7 -14
- package/lib/features/session-ops-routes.js +178 -253
- package/lib/features/session-ops.js +32 -29
- package/lib/repair.js +5 -3
- package/lib/seq-gap-repair.js +5 -5
- package/lib/sessions/events.js +11 -5
- package/lib/sessions/interruption.js +2 -2
- package/lib/sessions/lookup.js +50 -0
- package/lib/sessions/route.js +1 -1
- package/lib/shared/atomic-file.js +38 -0
- package/lib/shared/json-store.js +54 -0
- package/lib/shared/timing.js +37 -0
- package/lib/zstd-frames.js +6 -11
- package/package.json +1 -1
- package/skills/dsh-session-conductor-functions.md +1 -1
- package/skills/dsh-session-conductor.md +1 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// 创建团队成员,并让**第一轮真实任务**就用指定模型。
|
|
2
|
+
//
|
|
3
|
+
// 【为什么需要这一层】宿主 `spawn_teammate` 的参数里没有 provider/model,服务层的
|
|
4
|
+
// SpawnTeammateRequest 也只有 provider —— 模型只能来自 agent-team 插件的
|
|
5
|
+
// freshProvider/forkProvider 配置,所以「创建时就选模型」在原生入口做不到。
|
|
6
|
+
//
|
|
7
|
+
// 【为什么不只是「创建后切一下」】宿主是「创建即开跑」:直接带真实任务创建,第一轮一定用默认模型跑掉。
|
|
8
|
+
// 所以这里拆成四步:
|
|
9
|
+
// ① 用**占位任务**创建(几乎不花钱,也不会动任何文件)
|
|
10
|
+
// ② 立刻中断,把这一轮停掉
|
|
11
|
+
// ③ 把 model/selection 写进该成员的运行记录(与 set_member_model 同一条链路)
|
|
12
|
+
// ④ 再用新模型投递真实任务
|
|
13
|
+
// 这样真实任务从第一条指令起就用指定模型,而不是「先跑错一轮再切」。
|
|
14
|
+
//
|
|
15
|
+
// 【仍然存在的边界】① 占位那一轮确实发生过(只消耗一条极短提示的输入);
|
|
16
|
+
// ② 中断与切换之间存在极短窗口;③ 要彻底「创建前定模型」只能改宿主 agent-team
|
|
17
|
+
// (把 model 加进 SpawnTeammateRequest 并在创建时应用),宿主升级会覆盖。
|
|
18
|
+
//
|
|
19
|
+
// 【两个 "provider" 别混】宿主 spawnTeammate 的 provider = **子代理 provider**(注册在 ctx.subagents,
|
|
20
|
+
// 例如 spawn),而模型用的是 **模型 provider**(agnes/wb/d…)。两者是不同命名空间,
|
|
21
|
+
// 直接拿模型 provider 去创建会报 `no subagent provider registered for "x"`。
|
|
22
|
+
//
|
|
23
|
+
// 【权限】只有 Team Lead 能创建成员(宿主会抛 TEAM_LEAD_REQUIRED),调用方 agent 必须透传。
|
|
24
|
+
|
|
25
|
+
import { log } from "../../shared/log.js";
|
|
26
|
+
import { switchAgentModel } from "./switch.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 占位任务:只为让成员被创建出来,不产生实质动作。
|
|
30
|
+
* 写得极短是为了把首轮消耗压到最低(这一轮拿不到指定模型,越短越不亏)。
|
|
31
|
+
*/
|
|
32
|
+
const PLACEHOLDER_PROMPT = "先待命:不要执行任何操作,不要调用任何工具,不要读写任何文件,等待后续消息。";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 等成员真正空闲(中断的取消信号被观测完)再投递。
|
|
36
|
+
*
|
|
37
|
+
* 【为什么必须等】只看 `interrupt()` 的返回不够:它只是「发出取消」,成员可能仍在收尾;
|
|
38
|
+
* 这时立刻投递,新一轮会撞上同一个取消信号,表现为成员「stopped/failed before it finished」。
|
|
39
|
+
* 用宿主自己的 `waitForChange` 等状态变化(事件驱动),不睡固定时间。
|
|
40
|
+
* @returns {Promise<{idle:boolean, status:string}>}
|
|
41
|
+
*/
|
|
42
|
+
async function waitUntilIdle(teams, caller, name, signal, budget = 60) {
|
|
43
|
+
if (typeof teams?.listMembers !== 'function' || typeof teams?.waitForChange !== 'function') {
|
|
44
|
+
// 老宿主没有这两个能力:退化为「不等待」,行为与之前一致
|
|
45
|
+
return { idle: false, status: 'unknown' };
|
|
46
|
+
}
|
|
47
|
+
for (let round = 0; round < budget; round += 1) {
|
|
48
|
+
let member = null;
|
|
49
|
+
try {
|
|
50
|
+
const list = teams.listMembers(caller) ?? [];
|
|
51
|
+
member = list.find((m) => m?.name === name || m?.target === name) ?? null;
|
|
52
|
+
} catch {
|
|
53
|
+
return { idle: false, status: 'unknown' };
|
|
54
|
+
}
|
|
55
|
+
if (!member || member.status !== 'running') return { idle: true, status: member?.status ?? 'absent' };
|
|
56
|
+
try {
|
|
57
|
+
// 等「下一次团队状态变化」,超时也算一次观察窗口(不是业务超时,只是等待预算)
|
|
58
|
+
await teams.waitForChange(caller, 5000, signal);
|
|
59
|
+
} catch {
|
|
60
|
+
return { idle: false, status: member.status ?? 'unknown' };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { idle: false, status: 'still-running' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 创建成员 → 中断首轮 → 设置模型 → 投递真实任务。
|
|
68
|
+
* @param {object} ctx 插件上下文
|
|
69
|
+
* @param {object} exec 工具执行上下文(含 agent / signal)
|
|
70
|
+
* @param {object} args 工具参数
|
|
71
|
+
* @returns {Promise<
|
|
72
|
+
* {ok:true, member:object, interrupt:object, switchResult:object, delivery:object} |
|
|
73
|
+
* {ok:true, member:object, interrupt:object, switchResult:object, delivery:null, error:string} |
|
|
74
|
+
* {error:string}>}
|
|
75
|
+
*/
|
|
76
|
+
export async function createTeammateWithModel(ctx, exec, args) {
|
|
77
|
+
const name = String(args?.name ?? "").trim();
|
|
78
|
+
const description = String(args?.description ?? "").trim();
|
|
79
|
+
const prompt = String(args?.prompt ?? "").trim();
|
|
80
|
+
const modelProvider = String(args?.provider ?? "").trim(); // 模型 provider:agnes / wb / d …
|
|
81
|
+
const model = String(args?.model ?? "").trim(); // 模型 id
|
|
82
|
+
const context = args?.context === "fork" ? "fork" : "fresh";
|
|
83
|
+
|
|
84
|
+
if (!name || !description || !prompt) return { error: "name / description / prompt 都必填" };
|
|
85
|
+
if (!modelProvider || !model) return { error: "provider / model 都必填(先用 list_models 查确切值)" };
|
|
86
|
+
|
|
87
|
+
const teams = ctx.get("agentTeams");
|
|
88
|
+
if (typeof teams?.spawnTeammate !== "function") {
|
|
89
|
+
return { error: "agentTeams.spawnTeammate 不可用:当前 DSH 版本没有暴露创建接口" };
|
|
90
|
+
}
|
|
91
|
+
if (typeof teams?.sendMessage !== "function") {
|
|
92
|
+
return { error: "agentTeams.sendMessage 不可用:无法在创建后投递真实任务" };
|
|
93
|
+
}
|
|
94
|
+
const caller = exec?.agent ?? null;
|
|
95
|
+
if (!caller) return { error: "拿不到调用方 agent(该工具只能由 Team Lead 在自身的运行上下文里调用)" };
|
|
96
|
+
|
|
97
|
+
// 子代理 provider 与模型 provider 是两个命名空间:前者来自 ctx.subagents 注册表
|
|
98
|
+
let subagentProvider = String(args?.subagentProvider ?? "").trim();
|
|
99
|
+
if (!subagentProvider) {
|
|
100
|
+
const subagents = ctx.get("subagents");
|
|
101
|
+
const names = typeof subagents?.list === "function" ? subagents.list() : [];
|
|
102
|
+
subagentProvider = Array.isArray(names) && names.length > 0 ? String(names[0]) : "";
|
|
103
|
+
}
|
|
104
|
+
if (!subagentProvider) {
|
|
105
|
+
return { error: "没有可用的子代理 provider(ctx.subagents.list() 为空)——请在参数里显式传 subagentProvider" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
// signal:优先用工具调用自带的(可随宿主取消),否则用一个永不自废弃的控制器
|
|
110
|
+
const fallbackController = new AbortController();
|
|
111
|
+
const signal = exec?.signal ?? fallbackController.signal;
|
|
112
|
+
|
|
113
|
+
// ① 占位任务创建:真实任务留到模型设好之后再投
|
|
114
|
+
let spawned;
|
|
115
|
+
try {
|
|
116
|
+
spawned = await teams.spawnTeammate(caller, {
|
|
117
|
+
name,
|
|
118
|
+
description,
|
|
119
|
+
prompt: [{ type: "text", text: PLACEHOLDER_PROMPT }],
|
|
120
|
+
context,
|
|
121
|
+
provider: subagentProvider,
|
|
122
|
+
signal,
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return { error: `创建成员失败:${String(error?.message ?? error)}` };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const member = spawned?.member ?? null;
|
|
129
|
+
const sessionId = member?.id ? String(member.id) : "";
|
|
130
|
+
if (!sessionId) return { error: "创建成功但成员视图里没有 id,无法继续设置模型" };
|
|
131
|
+
|
|
132
|
+
// ② 立刻中断:占位那一轮不该继续跑
|
|
133
|
+
let interrupt = { previousStatus: "unknown" };
|
|
134
|
+
let interruptError = "";
|
|
135
|
+
try {
|
|
136
|
+
interrupt = await teams.interrupt(caller, name);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// 中断失败不致命:成员可能刚好已经空闲(那就取不到 previousStatus)
|
|
139
|
+
interruptError = String(error?.message ?? error);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ②·5 等它真正空闲:否则紧接着的投递会撞上同一个取消信号(实测表现为「stopped before it finished」)
|
|
143
|
+
const idleState = await waitUntilIdle(teams, caller, name, signal);
|
|
144
|
+
log(ctx, `创建成员 ${name}:中断后等待空闲 → ${JSON.stringify(idleState)}`);
|
|
145
|
+
|
|
146
|
+
// ③ 写 model/selection:与 set_member_model 完全同一条链路(默认不碰任何 @Remote)
|
|
147
|
+
const switchResult = await switchAgentModel(ctx, sessionId, modelProvider, model, null);
|
|
148
|
+
log(ctx, `创建成员 ${name}(${sessionId})| 子代理provider=${subagentProvider} | 中断=${JSON.stringify(interrupt)} | 设模型 ${modelProvider}/${model}=${switchResult.error ? `失败 ${switchResult.error}` : "已写入"}`);
|
|
149
|
+
|
|
150
|
+
if (switchResult.error) {
|
|
151
|
+
// 模型没设上就不投递:否则真实任务会用默认模型跑掉,等于白跑
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
member,
|
|
155
|
+
interrupt,
|
|
156
|
+
switchResult,
|
|
157
|
+
delivery: null,
|
|
158
|
+
error: `模型未设置成功(${switchResult.error}),已放弃投递真实任务;修好后可用 set_member_model + send_to_session 补`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ④ 投递真实任务:成员此时空闲,这条消息会唤醒它并用新模型执行
|
|
163
|
+
let delivery = null;
|
|
164
|
+
try {
|
|
165
|
+
delivery = await teams.sendMessage(caller, {
|
|
166
|
+
target: name,
|
|
167
|
+
content: [{ type: "text", text: prompt }],
|
|
168
|
+
signal,
|
|
169
|
+
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
member,
|
|
174
|
+
interrupt,
|
|
175
|
+
switchResult,
|
|
176
|
+
delivery: null,
|
|
177
|
+
error: `模型已设置,但投递真实任务失败:${String(error?.message ?? error)}`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { ok: true, member, interrupt, idleState, switchResult, delivery, subagentProvider, ...(interruptError ? { interruptError } : {}) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 把 createTeammateWithModel 的结果渲染成给模型看的文本。
|
|
186
|
+
* @param {{name:string, provider:string, model:string}} args
|
|
187
|
+
* @param {object} result
|
|
188
|
+
*/
|
|
189
|
+
export function renderCreateTeammateResult(args, result) {
|
|
190
|
+
if (!result?.ok) return `创建成员失败:${result?.error ?? "未知原因"}`;
|
|
191
|
+
const member = result.member ?? {};
|
|
192
|
+
const sessionId = String(member.id ?? "");
|
|
193
|
+
const interrupt = result.interrupt ?? {};
|
|
194
|
+
const switchResult = result.switchResult ?? {};
|
|
195
|
+
const delivery = result.delivery ?? null;
|
|
196
|
+
const lines = [
|
|
197
|
+
`已创建成员 ${member.name ?? args?.name ?? "?"}(${sessionId})`,
|
|
198
|
+
`流程:占位任务创建(子代理 provider=${result.subagentProvider ?? "?"})→ 中断(首轮状态 ${interrupt.previousStatus ?? "unknown"})→ 等空闲(${result.idleState?.status ?? "unknown"})→ 设模型 → 投递真实任务`,
|
|
199
|
+
`模型:${args?.provider ?? "?"}/${args?.model ?? "?"}(${switchResult.via ?? "已写入 model/selection"})`,
|
|
200
|
+
];
|
|
201
|
+
if (delivery) {
|
|
202
|
+
lines.push(`真实任务已投递:messageId=${delivery.messageId ?? "(无)"} —— 该成员会用上面的模型开始执行`);
|
|
203
|
+
}
|
|
204
|
+
if (result.interruptError) lines.push(`注:中断调用报错(${result.interruptError}),已按成员可能已空闲继续处理`);
|
|
205
|
+
if (result.error) lines.push(`⚠️ ${result.error}`);
|
|
206
|
+
lines.push("边界:占位那一轮确实发生过(只消耗一条极短提示);要「创建前定模型」只能改宿主 agent-team。");
|
|
207
|
+
return lines.join("\n");
|
|
208
|
+
}
|
|
@@ -3,37 +3,42 @@
|
|
|
3
3
|
// 【职责】把「切模型」的目标解析出来:成员名/sessionId/标题 → 会话,
|
|
4
4
|
// 并在切换前做状态门禁与 provider/model 组合校验。
|
|
5
5
|
|
|
6
|
+
import { cfg } from "../../core/config.js";
|
|
7
|
+
import { log } from "../../shared/log.js";
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* 取一个可用于 Team 名册查询的 caller agent(lead 优先)。
|
|
8
11
|
* agentTeams.listMembers(caller) 需要一个 Team 成员作为 caller 来解析名册。
|
|
9
12
|
* @returns {object|null} 可用 agent,取不到返回 null
|
|
10
13
|
*/
|
|
11
14
|
export function pickTeamCaller(ctx, preferred = null) {
|
|
12
|
-
const agents = ctx.get("agents");
|
|
13
|
-
if (!agents) return null;
|
|
14
15
|
// 0) 调用方显式给的发起方最可靠:命令/工具都能直接拿到 invocation.agent / exec.agent。
|
|
15
16
|
// currentInitiator() 依赖「当前在某个 agent 的异步链里」,命令处理器里经常拿不到 ——
|
|
16
17
|
// 这正是「工具能切、聊天指令说找不到成员」的根因。
|
|
17
18
|
if (preferred) return preferred;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
try {
|
|
25
|
-
const roots = agents.roots?.() ?? [];
|
|
26
|
-
if (roots.length > 0) return roots[0];
|
|
27
|
-
} catch {
|
|
28
|
-
// roots 不可用时忽略
|
|
19
|
+
const agents = ctx.get("agents");
|
|
20
|
+
if (!agents) return null;
|
|
21
|
+
for (const probe of CALLER_PROBES) {
|
|
22
|
+
const hit = safeProbe(probe, agents);
|
|
23
|
+
if (hit) return hit;
|
|
29
24
|
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 依次尝试的发起方来源(按可靠性从高到低)。 */
|
|
29
|
+
const CALLER_PROBES = [
|
|
30
|
+
(agents) => agents.currentInitiator?.(),
|
|
31
|
+
(agents) => (agents.roots?.() ?? [])[0] ?? null,
|
|
32
|
+
(agents) => (agents.list?.() ?? [])[0] ?? null,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** 任一来源不可用/抛错都当作「没有」,不影响下一个来源。 */
|
|
36
|
+
function safeProbe(probe, agents) {
|
|
30
37
|
try {
|
|
31
|
-
|
|
32
|
-
if (list.length > 0) return list[0];
|
|
38
|
+
return probe(agents) ?? null;
|
|
33
39
|
} catch {
|
|
34
|
-
|
|
40
|
+
return null;
|
|
35
41
|
}
|
|
36
|
-
return null;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/**
|
|
@@ -68,12 +73,21 @@ export function memberStatusError(member) {
|
|
|
68
73
|
* @returns {Promise<{ok: true, note?: string} | {error: string}>}
|
|
69
74
|
*/
|
|
70
75
|
export async function validateModelPair(ctx, provider, model) {
|
|
76
|
+
// 组合校验只是「提前拦住无效组合」,不该成为切换的必经阻塞点:
|
|
77
|
+
// · 默认就是关闭(modelSwitchValidateCatalog 缺省 false)——@Remote 取目录可能久不返回;
|
|
78
|
+
// · 无论成败都把耗时打出来(sessionController.modelCatalog 是 @Remote,某些部署上很久才返回)。
|
|
79
|
+
if (cfg.modelSwitchValidateCatalog === false) {
|
|
80
|
+
return { ok: true, note: "已按配置跳过组合校验(modelSwitchValidateCatalog=false)" };
|
|
81
|
+
}
|
|
71
82
|
let catalog = null;
|
|
83
|
+
const startedAt = Date.now();
|
|
72
84
|
try {
|
|
73
85
|
const sc = ctx.get("sessionController");
|
|
74
86
|
catalog = sc?.modelCatalog ? await sc.modelCatalog() : null;
|
|
75
87
|
} catch {
|
|
76
88
|
catalog = null;
|
|
89
|
+
} finally {
|
|
90
|
+
log(ctx, `模型组合校验取目录耗时 ${Date.now() - startedAt}ms(分组数 ${(catalog?.groups ?? []).length})`);
|
|
77
91
|
}
|
|
78
92
|
const groups = catalog?.groups ?? [];
|
|
79
93
|
if (groups.length === 0) return { ok: true, note: "modelCatalog 不可用,已跳过组合校验" };
|
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
// 【职责】把一次「切模型」落到实处:持久化 override、必要时等回合暂停、
|
|
4
4
|
// 写 `model/selection` 事件(可观测 + 官方投影),未挂载时直接改会话日志文件。
|
|
5
5
|
|
|
6
|
-
import { readFileSync, copyFileSync,
|
|
6
|
+
import { readFileSync, copyFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { replaceFileSync } from "../../shared/atomic-file.js";
|
|
7
9
|
import path from "node:path";
|
|
8
10
|
import { log } from "../../shared/log.js";
|
|
11
|
+
import { createStageTimer } from "../../shared/timing.js";
|
|
12
|
+
import { cfg } from "../../core/config.js";
|
|
9
13
|
import { hasOpenTurn } from "../../sessions/turn.js";
|
|
10
14
|
import { foldLastModelSelection } from "../../sessions/route.js";
|
|
11
15
|
import { lazyZstd, lazyRepair } from "../../core/lazy.js";
|
|
@@ -48,58 +52,67 @@ export async function waitForIdle(agent, timeoutMs = 60000) {
|
|
|
48
52
|
*
|
|
49
53
|
* @returns {Promise<{ok: true, note?: string} | {error: string}>}
|
|
50
54
|
*/
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
/** 读出会话日志的文本行与事件(定位失败/解码失败/空日志返回 error)。 */
|
|
56
|
+
async function readSwitchLog(sessionId) {
|
|
57
|
+
// 0.2.x 已移除 persistence.listArtifacts(原实现因此恒报「服务不可用」,整条写日志路径是死的),
|
|
58
|
+
// 改为按 id 直接定位日志文件 —— 与删除链路用同一套定位(findSessionLogById)。
|
|
59
|
+
const { findSessionLogById, sessionsRootOf } = await import("../../sessions/events.js");
|
|
60
|
+
const filePath = findSessionLogById(sessionId, sessionsRootOf());
|
|
61
|
+
if (!filePath) return { error: `会话日志不存在(sessionId=${sessionId})` };
|
|
58
62
|
const { decodeAllFrames } = await lazyZstd();
|
|
59
63
|
let text;
|
|
60
64
|
try {
|
|
61
|
-
text = await decodeAllFrames(
|
|
65
|
+
text = await decodeAllFrames(await readFile(filePath));
|
|
62
66
|
} catch (error) {
|
|
63
67
|
return { error: `会话日志解码失败:${String(error?.message ?? error)}` };
|
|
64
68
|
}
|
|
65
|
-
const lines = String(text).split("\n").filter((
|
|
69
|
+
const lines = String(text).split("\n").filter((line) => line.trim() !== "");
|
|
66
70
|
if (lines.length === 0) return { error: "会话日志为空" };
|
|
67
71
|
const events = [];
|
|
68
|
-
for (let i = 1; i < lines.length; i
|
|
72
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
69
73
|
try {
|
|
70
74
|
events.push(JSON.parse(lines[i]));
|
|
71
75
|
} catch {
|
|
72
|
-
//
|
|
76
|
+
// 跳过坏行(写入前的 validateSessionText 会兜底)
|
|
73
77
|
}
|
|
74
78
|
}
|
|
79
|
+
return { filePath, lines, events };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 写回前先备份(备份失败就不写,避免没有回滚点)。 */
|
|
83
|
+
function backupSwitchLog(filePath, sessionId) {
|
|
84
|
+
const backupDir = path.join(path.dirname(filePath), ".model-switch-backup");
|
|
85
|
+
try {
|
|
86
|
+
mkdirSync(backupDir, { recursive: true });
|
|
87
|
+
copyFileSync(filePath, path.join(backupDir, `${Date.now()}-${sessionId}.zstd`));
|
|
88
|
+
return { ok: true };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return { error: `备份失败(未写入):${String(error?.message ?? error)}` };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function appendSelectionEventToLog(ctx, sessionId, provider, model) {
|
|
95
|
+
const startedAt = Date.now();
|
|
96
|
+
const logInfo = await readSwitchLog(sessionId);
|
|
97
|
+
if (logInfo.error) return { error: logInfo.error };
|
|
98
|
+
const { filePath, lines, events } = logInfo;
|
|
99
|
+
// 这条路径要整份解码会话日志(几十 MB 时会明显耗时),把耗时单独记出来才能和「卡住」区分
|
|
100
|
+
log(ctx, `切换写日志:定位+解码 ${Date.now() - startedAt}ms(行数 ${lines.length})`);
|
|
75
101
|
const last = foldLastModelSelection(events);
|
|
76
102
|
if (last && last.provider === provider && last.model === model) {
|
|
77
103
|
return { ok: true, note: "日志里已是该模型,无需重复写入" };
|
|
78
104
|
}
|
|
79
|
-
|
|
80
|
-
for (const e of events) if (typeof e?.seq === "number" && e.seq > maxSeq) maxSeq = e.seq;
|
|
105
|
+
const maxSeq = events.reduce((max, e) => (typeof e?.seq === "number" && e.seq > max ? e.seq : max), -1);
|
|
81
106
|
const event = { type: "model/selection", seq: maxSeq + 1, time: Date.now(), data: { provider, model } };
|
|
82
107
|
const newText = [...lines, JSON.stringify(event)].join("\n") + "\n";
|
|
83
108
|
const { validateSessionText, encodeSessionText } = await lazyRepair();
|
|
84
109
|
const check = validateSessionText(newText);
|
|
85
110
|
if (!check.ok) return { error: `写入后校验失败:${check.problems?.[0] ?? "未知"}` };
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
} catch (error) {
|
|
92
|
-
return { error: `备份失败(未写入):${String(error?.message ?? error)}` };
|
|
93
|
-
}
|
|
94
|
-
const newBuf = await encodeSessionText(newText);
|
|
95
|
-
const switchTmpPath = filePath + ".modelswitchtmp";
|
|
96
|
-
writeFileSync(switchTmpPath, newBuf);
|
|
97
|
-
try {
|
|
98
|
-
chmodSync(switchTmpPath, statSync(filePath).mode);
|
|
99
|
-
} catch {
|
|
100
|
-
// CIFS 无 chmod,尽力
|
|
101
|
-
}
|
|
102
|
-
renameSync(switchTmpPath, filePath);
|
|
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`);
|
|
103
116
|
return { ok: true, note: "已直接写入会话日志" };
|
|
104
117
|
}
|
|
105
118
|
|
|
@@ -120,21 +133,81 @@ export async function appendSelectionEventToLog(ctx, sessionId, provider, model)
|
|
|
120
133
|
* @param {object|null} agent 可选 live agent(不传则内部按 sessionId 查)
|
|
121
134
|
* @returns {{via: string} | {error: string}}
|
|
122
135
|
*/
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
/**
|
|
137
|
+
* ① 持久化切换意图:不依赖 live agent —— 成员 inactive/未挂载时也能切,下次运行生效。
|
|
138
|
+
* @returns {Promise<{note: string}>} note 直接拼进回执
|
|
139
|
+
*/
|
|
140
|
+
async function persistSwitchIntent(ctx, sessionId, provider, model) {
|
|
127
141
|
try {
|
|
128
142
|
await setMemberModelOverride(ctx, sessionId, provider, model);
|
|
129
|
-
|
|
143
|
+
return { note: ",已记录切换意图" };
|
|
130
144
|
} catch (error) {
|
|
131
|
-
|
|
145
|
+
return { note: `,意图持久化失败:${String(error?.message ?? error)}` };
|
|
132
146
|
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* ⑤ 官方接口优先(若该部署暴露):
|
|
151
|
+
* · 新版 ctx.apiProxy.sessions.selectModel(会话级模型选择,持久化到会话)
|
|
152
|
+
* · 旧版 ctx.get("sessionController").selectModel(Typert @Remote,服务端直调可能不可用)
|
|
153
|
+
* 任一成功返回 {via};都不可用/失败返回 null(调用方回退到「写事件」机制)。
|
|
154
|
+
*/
|
|
155
|
+
async function tryOfficialSelectModel(ctx, sessionId, provider, model, overrideNote) {
|
|
156
|
+
// 可关:某些部署上 selectModel(Typert @Remote)会长时间不返回,表现为「切模型卡住」。
|
|
157
|
+
// 配置 modelSwitchOfficialApi=false 即只走「写 model/selection 事件」这条实测可用的路径。
|
|
158
|
+
if (cfg.modelSwitchOfficialApi === false) {
|
|
159
|
+
log(ctx, "已按配置跳过官方 selectModel(modelSwitchOfficialApi=false),仅写事件");
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const apiProxy = ctx.apiProxy ?? (typeof ctx.get === "function" ? ctx.get("apiProxy") : null);
|
|
163
|
+
const proxySessions = apiProxy?.sessions;
|
|
164
|
+
if (typeof proxySessions?.selectModel === "function") {
|
|
165
|
+
const startedAt = Date.now();
|
|
166
|
+
try {
|
|
167
|
+
await proxySessions.selectModel({ sessionId, provider, model });
|
|
168
|
+
log(ctx, `apiProxy.sessions.selectModel 成功,耗时 ${Date.now() - startedAt}ms`);
|
|
169
|
+
return { via: `ctx.apiProxy.sessions.selectModel(官方,会话级持久)${overrideNote}` };
|
|
170
|
+
} catch (error) {
|
|
171
|
+
log(ctx, `apiProxy.sessions.selectModel 失败(耗时 ${Date.now() - startedAt}ms,回退事件机制): ${String(error?.message ?? error)}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const sc = ctx.get("sessionController");
|
|
175
|
+
if (typeof sc?.selectModel !== "function") return null;
|
|
176
|
+
const remoteStartedAt = Date.now();
|
|
177
|
+
try {
|
|
178
|
+
await sc.selectModel({ sessionId, provider, model });
|
|
179
|
+
log(ctx, `sessionController.selectModel 成功,耗时 ${Date.now() - remoteStartedAt}ms`);
|
|
180
|
+
return { via: `sessionController.selectModel(官方,立即生效)${overrideNote}` };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
// 服务端直调 @Remote 可能不被支持——已走「写事件」机制,这里只留日志
|
|
183
|
+
log(ctx, `selectModel 直调不可用(耗时 ${Date.now() - remoteStartedAt}ms,已用 model/selection 事件机制): ${String(error?.message ?? error)}`);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function switchAgentModel(ctx, sessionId, provider, model, agent = null) {
|
|
189
|
+
const timer = createStageTimer();
|
|
190
|
+
try {
|
|
191
|
+
return await applyModelSwitch(ctx, sessionId, provider, model, agent, timer);
|
|
192
|
+
} finally {
|
|
193
|
+
// 无论成功失败都留一行分阶段耗时:排查「切模型卡很久」时直接看慢在哪一段
|
|
194
|
+
log(ctx, `模型切换耗时:${timer.summary()}(标记合计 ${timer.total()}ms)`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 切换主流程(逐段打点,计时器只观测、不改变行为)。 */
|
|
199
|
+
async function applyModelSwitch(ctx, sessionId, provider, model, agent, timer) {
|
|
200
|
+
if (!sessionId) return { error: "缺少 sessionId(无法定位目标会话)" };
|
|
201
|
+
// ① 持久化切换意图(不依赖 live agent——成员 inactive/未挂载时也能切,下次运行生效)
|
|
202
|
+
const { note: overrideNote } = await persistSwitchIntent(ctx, sessionId, provider, model);
|
|
203
|
+
timer.mark("写意图");
|
|
133
204
|
const live = agent ?? ctx.get("agents")?.get?.(sessionId) ?? null;
|
|
205
|
+
timer.mark("定位成员");
|
|
134
206
|
// ② 成员当前未挂载(inactive):直接把事件写进会话日志——官方 selectionFor 读投影即生效。
|
|
135
207
|
// (Team 成员唤醒走 sendMessage → followup,不经过 resumeSetupFor,只记意图不会被应用。)
|
|
136
208
|
if (typeof live?.ctx?.on !== "function" || !live?.session) {
|
|
137
209
|
const written = await appendSelectionEventToLog(ctx, sessionId, provider, model);
|
|
210
|
+
timer.mark("写会话日志");
|
|
138
211
|
if (written.error) {
|
|
139
212
|
return { error: `成员未挂载,且写入会话日志失败:${written.error}(切换意图已记录,可稍后重试)` };
|
|
140
213
|
}
|
|
@@ -143,7 +216,9 @@ export async function switchAgentModel(ctx, sessionId, provider, model, agent =
|
|
|
143
216
|
// ③ 正在执行回合:等它暂停再切(当前回合已用旧模型发出请求,直接切会「只生效一回合」)
|
|
144
217
|
const IDLE_WAIT_MS = 60_000;
|
|
145
218
|
if (live.status === "running" || hasOpenTurn(live.session?.events)) {
|
|
219
|
+
log(ctx, `目标成员在运行中(status=${live.status ?? "?"}),先等其空闲再切(最多 ${IDLE_WAIT_MS}ms)`);
|
|
146
220
|
const idle = await waitForIdle(live, IDLE_WAIT_MS);
|
|
221
|
+
timer.mark("等空闲");
|
|
147
222
|
if (!idle) {
|
|
148
223
|
return { error: `成员正在执行回合(status=${live.status ?? "?"}),已等待 60s 仍未结束——请稍后重试,或先用 interrupt_agent 中断该成员` };
|
|
149
224
|
}
|
|
@@ -154,6 +229,7 @@ export async function switchAgentModel(ctx, sessionId, provider, model, agent =
|
|
|
154
229
|
try {
|
|
155
230
|
live.session.append("model/selection", { provider, model });
|
|
156
231
|
eventNote = ",已写入 model/selection 事件";
|
|
232
|
+
timer.mark("写事件");
|
|
157
233
|
} catch (error) {
|
|
158
234
|
eventNote = `,事件写入失败:${String(error?.message ?? error)}`;
|
|
159
235
|
}
|
|
@@ -162,26 +238,9 @@ export async function switchAgentModel(ctx, sessionId, provider, model, agent =
|
|
|
162
238
|
// · 新版:ctx.apiProxy.sessions.selectModel(会话级模型选择,持久化到会话)
|
|
163
239
|
// · 旧版:ctx.get("sessionController").selectModel(Typert @Remote,服务端直调可能不可用)
|
|
164
240
|
// 两者任一成功都直接返回——它们比「只写事件」更权威(含模型归一化等官方处理)。
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
try {
|
|
169
|
-
await proxySessions.selectModel({ sessionId, provider, model });
|
|
170
|
-
return { via: `ctx.apiProxy.sessions.selectModel(官方,会话级持久)${overrideNote}` };
|
|
171
|
-
} catch (error) {
|
|
172
|
-
log(ctx, `apiProxy.sessions.selectModel 失败(回退事件机制): ${String(error?.message ?? error)}`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
const sc = ctx.get("sessionController");
|
|
176
|
-
if (typeof sc?.selectModel === "function") {
|
|
177
|
-
try {
|
|
178
|
-
await sc.selectModel({ sessionId, provider, model });
|
|
179
|
-
via = `sessionController.selectModel(官方,立即生效)${overrideNote}`;
|
|
180
|
-
} catch (error) {
|
|
181
|
-
// 服务端直调 @Remote 可能不被支持——已走上面的「写事件」机制,这里只留日志
|
|
182
|
-
log(ctx, `selectModel 直调不可用(已用 model/selection 事件机制): ${String(error?.message ?? error)}`);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
241
|
+
const official = await tryOfficialSelectModel(ctx, sessionId, provider, model, overrideNote);
|
|
242
|
+
timer.mark("官方接口");
|
|
243
|
+
if (official) return official;
|
|
185
244
|
return { via };
|
|
186
245
|
}
|
|
187
246
|
|
|
@@ -250,6 +309,26 @@ export function resetSwitchWatchForTest() {
|
|
|
250
309
|
pendingOutcomes.clear();
|
|
251
310
|
}
|
|
252
311
|
|
|
312
|
+
/**
|
|
313
|
+
* 把一个会话事件归类成「请求头」或「切换意图」,都不是则返回 null。
|
|
314
|
+
* 抽出来是为了让 readLastRequestModel 的流式解析循环只负责「读一行 → 交给它判」。
|
|
315
|
+
* @returns {{kind:'request'|'selection', value:{provider:string, model:string, time:number|null}}|null}
|
|
316
|
+
*/
|
|
317
|
+
function modelEventOf(event) {
|
|
318
|
+
if (event?.type === "request/header") {
|
|
319
|
+
const config = event?.data?.header?.config;
|
|
320
|
+
if (!config?.provider || !config?.model) return null;
|
|
321
|
+
return { kind: "request", value: { provider: config.provider, model: config.model, time: event.time ?? null } };
|
|
322
|
+
}
|
|
323
|
+
if (event?.type === "model/selection" && event?.data?.provider) {
|
|
324
|
+
return {
|
|
325
|
+
kind: "selection",
|
|
326
|
+
value: { provider: event.data.provider, model: event.data.model, time: event.time ?? null },
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
253
332
|
/**
|
|
254
333
|
* 读会话日志里「最近一次实际请求使用的模型」与「最近一次切换意图」。
|
|
255
334
|
*
|
|
@@ -266,7 +345,7 @@ export async function readLastRequestModel(sessionId, root = null) {
|
|
|
266
345
|
if (!file) return { lastRequest: null, lastSelection: null, scanned: 0 };
|
|
267
346
|
const { streamDecodedFrames } = await import("../../zstd-frames.js");
|
|
268
347
|
const { StringDecoder } = await import("node:string_decoder");
|
|
269
|
-
const buf =
|
|
348
|
+
const buf = await readFile(file); // 日志最大可达数十 MB,异步读避免阻塞事件循环
|
|
270
349
|
const decoder = new StringDecoder("utf8");
|
|
271
350
|
let pending = "";
|
|
272
351
|
let scanned = 0;
|
|
@@ -276,13 +355,13 @@ export async function readLastRequestModel(sessionId, root = null) {
|
|
|
276
355
|
if (!line) return;
|
|
277
356
|
let event;
|
|
278
357
|
try { event = JSON.parse(line); } catch { return; }
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
lastSelection =
|
|
358
|
+
const hit = modelEventOf(event);
|
|
359
|
+
if (hit === null) return;
|
|
360
|
+
if (hit.kind === "request") {
|
|
361
|
+
lastRequest = hit.value;
|
|
362
|
+
scanned += 1;
|
|
363
|
+
} else {
|
|
364
|
+
lastSelection = hit.value;
|
|
286
365
|
}
|
|
287
366
|
};
|
|
288
367
|
await streamDecodedFrames(buf, (chunk) => {
|