dsh-advisor-group 0.1.0 → 0.1.2
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 +132 -100
- package/README.zh.md +135 -100
- package/lib/client.js +855 -116
- package/lib/index.d.mts +108 -1
- package/lib/index.mjs +3191 -479
- package/package.json +42 -28
package/lib/index.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import Schema from "@deepseek-ai/schemastery";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { promises, readFileSync } from "node:fs";
|
|
3
|
+
import { appendFileSync, mkdirSync, promises, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
5
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
6
|
+
import { KNOWN_SESSION_EVENT_TYPES, Session } from "@deepseek-ai/dsh-session";
|
|
6
7
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
7
8
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
8
9
|
import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
|
|
9
|
-
import { KNOWN_SESSION_EVENT_TYPES } from "@deepseek-ai/dsh-session";
|
|
10
10
|
//#region src/defaults.ts
|
|
11
11
|
const DEFAULT_ADVISOR_PROMPT = `
|
|
12
12
|
你是顾问群中的资深专家。你的任务不是替用户执行,而是提供高质量的专业意见供主模型参考。
|
|
@@ -47,7 +47,19 @@ const AdvisorConfig = Schema.object({
|
|
|
47
47
|
clearApiKey: Schema.boolean().description("Transient client intent: clear the current provider direct key. Stripped before persistence."),
|
|
48
48
|
apiKeyEnv: Schema.string().description("Optional env var name for direct-http fallback."),
|
|
49
49
|
protocol: ProviderProtocol,
|
|
50
|
-
authMode: ProviderAuthMode.description("Anthropic auth header style: x-api-key or bearer.")
|
|
50
|
+
authMode: ProviderAuthMode.description("Anthropic auth header style: x-api-key or bearer."),
|
|
51
|
+
thinking: Schema.union([Schema.const("enabled"), Schema.const("disabled")]).description("Per-advisor thinking mode; unset = provider default. disabled forces no thinking block (DSH built-in channel maps it to reasoningEffort off)."),
|
|
52
|
+
reasoningEffort: Schema.union([
|
|
53
|
+
Schema.const("off"),
|
|
54
|
+
Schema.const("low"),
|
|
55
|
+
Schema.const("high"),
|
|
56
|
+
Schema.const("max")
|
|
57
|
+
]).description("Per-advisor thinking strength; unset = provider/model default. Standard DSH effort ids; off disables thinking per request."),
|
|
58
|
+
tools: Schema.union([
|
|
59
|
+
Schema.const("readonly"),
|
|
60
|
+
Schema.const("all"),
|
|
61
|
+
Schema.const("off")
|
|
62
|
+
]).description("Per-advisor tool calling scope override; unset = follow the global discussion.advisorTools default.")
|
|
51
63
|
});
|
|
52
64
|
const DriverModel = Schema.object({
|
|
53
65
|
provider: Schema.string().required().description("Provider route of the fallback driver model."),
|
|
@@ -60,12 +72,34 @@ const DiscussionConfig = Schema.object({
|
|
|
60
72
|
autoDeepen: Schema.boolean().default(true).description("Run the auto-deepen pipeline: after each round the driver model asks a deeper follow-up, and a final conclusion closes the consultation."),
|
|
61
73
|
driverModel: Schema.union([DriverModel, Schema.const(void 0)]).description("Fallback driver model (provider/model) used when the session header cannot be read."),
|
|
62
74
|
stopOnConsensus: Schema.boolean().default(false).description("Deprecated: dormant under the auto-deepen pipeline; kept for stored-config compatibility."),
|
|
63
|
-
advisorTimeoutMs: Schema.number().min(1e3).max(6e5).default(
|
|
75
|
+
advisorTimeoutMs: Schema.number().min(1e3).max(6e5).default(6e5).description("Per-advisor call timeout in milliseconds (default 600000 = 10 min). Long-reasoning models can exhaust shorter budgets on the thinking chain and lose the answer body; a timeout keeps the partial thinking chain and marks the message as truncated."),
|
|
76
|
+
driverTimeoutMs: Schema.number().min(1e3).max(12e5).default(6e5).description("Driver generation timeout in milliseconds (deep-question / conclusion; default 600000 = 10 min). Long discussions with many tool results need a real budget."),
|
|
77
|
+
advisorTools: Schema.union([
|
|
78
|
+
Schema.const("readonly"),
|
|
79
|
+
Schema.const("all"),
|
|
80
|
+
Schema.const("off")
|
|
81
|
+
]).default("readonly").description("Global default advisor tool calling scope for advisors that do not set their own `tools` field: readonly (default; read-only session tools), all (every session-visible tool incl. writable ones), off (disabled). Only the direct-http OpenAI/Anthropic channel supports tool calling.")
|
|
64
82
|
});
|
|
65
83
|
const TriggerConfig = Schema.object({
|
|
66
84
|
requireClassifier: Schema.boolean().default(true).description("Run the pre-classifier before starting a consultation."),
|
|
67
85
|
allowWebFallback: Schema.boolean().default(true).description("When classifier says web search is better, return that hint to the main model."),
|
|
68
|
-
confidenceThreshold: Schema.number().min(0).max(1).default(.6).description("Below this confidence score, ask_advisors should escalate even without domain keywords.")
|
|
86
|
+
confidenceThreshold: Schema.number().min(0).max(1).default(.6).description("Below this confidence score, ask_advisors should escalate even without domain keywords."),
|
|
87
|
+
jev: Schema.object({
|
|
88
|
+
enabled: Schema.boolean().default(false).description("Use Jev for semantic pre-classification when configured."),
|
|
89
|
+
provider: Schema.union([Schema.const("typesafe"), Schema.const("openrouter")]).default("typesafe"),
|
|
90
|
+
model: Schema.string().default("jev-latest"),
|
|
91
|
+
baseURL: Schema.string().description("Optional custom Jev API endpoint."),
|
|
92
|
+
apiKey: Schema.string().role("secret").description("Jev API key; prefer apiKeyEnv when possible."),
|
|
93
|
+
apiKeyEnv: Schema.string().description("Optional environment variable containing the Jev API key."),
|
|
94
|
+
timeoutMs: Schema.number().min(1e3).max(6e4).default(1e4),
|
|
95
|
+
confidenceThreshold: Schema.number().min(0).max(1).default(.6),
|
|
96
|
+
useEnglishState: Schema.boolean().default(false).description("Judge with the caller-provided English gist (questionEn) as the Jev state instead of the Chinese question; display and session records stay Chinese."),
|
|
97
|
+
highRiskThreshold: Schema.number().min(0).max(1).description("Threshold for the high_risk question; unset means follow confidenceThreshold."),
|
|
98
|
+
apiKeyMeta: Schema.object({
|
|
99
|
+
configured: Schema.boolean(),
|
|
100
|
+
last4: Schema.string()
|
|
101
|
+
}).description("Server-only API key presence metadata.")
|
|
102
|
+
}).default({})
|
|
69
103
|
});
|
|
70
104
|
const UiConfig = Schema.object({
|
|
71
105
|
theme: Schema.union([
|
|
@@ -76,13 +110,24 @@ const UiConfig = Schema.object({
|
|
|
76
110
|
showTimestamps: Schema.boolean().default(true),
|
|
77
111
|
autoExpand: Schema.boolean().default(true)
|
|
78
112
|
});
|
|
113
|
+
const QuotaConfig = Schema.object({
|
|
114
|
+
enabled: Schema.boolean().default(true).description("Enable the daily new-consultation cap (cost safety valve)."),
|
|
115
|
+
maxPerDay: Schema.natural().min(1).max(1e5).default(50).description("Max new consultations per UTC day (1–100000); ignored when enabled is false.")
|
|
116
|
+
});
|
|
79
117
|
const Config = Schema.object({
|
|
80
|
-
enabled: Schema.boolean().default(true),
|
|
81
|
-
discussion: DiscussionConfig,
|
|
82
|
-
trigger: TriggerConfig,
|
|
83
|
-
ui: UiConfig,
|
|
84
|
-
|
|
118
|
+
enabled: Schema.boolean().default(true).volatile(),
|
|
119
|
+
discussion: DiscussionConfig.volatile(),
|
|
120
|
+
trigger: TriggerConfig.volatile(),
|
|
121
|
+
ui: UiConfig.volatile(),
|
|
122
|
+
quota: QuotaConfig.volatile(),
|
|
123
|
+
advisors: Schema.array(AdvisorConfig).default([]).volatile()
|
|
85
124
|
});
|
|
125
|
+
function unwrapVolatileConfig(value) {
|
|
126
|
+
if (value && typeof value === "object" && "get" in value && typeof value.get === "function") return unwrapVolatileConfig(value.get());
|
|
127
|
+
if (Array.isArray(value)) return value.map((item) => unwrapVolatileConfig(item));
|
|
128
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, unwrapVolatileConfig(item)]));
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
86
131
|
//#endregion
|
|
87
132
|
//#region src/providers/advisor-prompt.ts
|
|
88
133
|
/**
|
|
@@ -96,7 +141,23 @@ const ADVISOR_OUTPUT_POLICY = `
|
|
|
96
141
|
回答要求:
|
|
97
142
|
- 先给结论,再简要说明依据;
|
|
98
143
|
- 在回答末尾用一行标注:置信度(0-10)或不确定性;
|
|
99
|
-
-
|
|
144
|
+
- 如果没有把握,请明确写“不确定”,不要强行编造;
|
|
145
|
+
- **全程使用中文输出**:包括调用工具前的行动说明、正文与最终结论(代码、路径、专业术语可保留原文);不要把“我接下来要做什么”的说明写成英文;
|
|
146
|
+
- 思考(思维链)请保持精简:只列关键推理要点,切勿让思考链占满输出预算——必须保证正文完整(历史教训:长思考+长上下文会耗尽输出上限,导致正文丢失/中途截断);
|
|
147
|
+
- **最终答复必须输出一段完整的中文正文回答**:不要只输出工具调用描述/标记,不要输出空白或空消息——即使本轮没有新结论,也要用正文说明你的判断(这是硬性输出约束)。
|
|
148
|
+
`;
|
|
149
|
+
/**
|
|
150
|
+
* Tool-calling guidance appended to an advisor's system prompt ONLY when the
|
|
151
|
+
* advisor actually has tools configured and the session-visible set resolved
|
|
152
|
+
* (callAdvisor adds it on the direct-http tool loop path).
|
|
153
|
+
*/
|
|
154
|
+
const ADVISOR_TOOL_GUIDANCE = `
|
|
155
|
+
|
|
156
|
+
工具使用指引:
|
|
157
|
+
- 你可以调用当前会话提供的工具(检索/读取/搜索等)来辅助回答;
|
|
158
|
+
- 对于你不了解、不熟悉的内容,尤其是项目背景、代码细节、仓库状态等不在你知识范围内的情况,请优先使用联网工具(如 web_search、web_fetch)搜索核实,再基于实际信息给出意见;
|
|
159
|
+
- 仅在确有必要时调用工具:先了解现状,再下结论;不要编造你未核实的事实;
|
|
160
|
+
- **调用工具前必须先用一句中文说明你准备做什么**(行动说明),例如“我先读取 package.json 确认版本”;这段行动说明会显示在卡片上,不要用英文写。
|
|
100
161
|
`;
|
|
101
162
|
/**
|
|
102
163
|
* Sequential-relay role hint appended to one advisor's system prompt.
|
|
@@ -129,6 +190,18 @@ function withTimeout(ms, signal) {
|
|
|
129
190
|
if (!signal) return timeout;
|
|
130
191
|
return AbortSignal.any([signal, timeout]);
|
|
131
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Same combination, but keeps the timeout signal reachable so the caller can
|
|
195
|
+
* classify an aborted stream: timeout fired -> graceful truncation (keep the
|
|
196
|
+
* partial thinking, mark `truncated`); caller signal fired -> cancellation.
|
|
197
|
+
*/
|
|
198
|
+
function timeoutSignalPair(ms, signal) {
|
|
199
|
+
const timeout = AbortSignal.timeout(ms);
|
|
200
|
+
return {
|
|
201
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
202
|
+
isTimeout: () => timeout.aborted
|
|
203
|
+
};
|
|
204
|
+
}
|
|
132
205
|
//#endregion
|
|
133
206
|
//#region src/providers/ctx-llm.ts
|
|
134
207
|
function formatTranscript(entries) {
|
|
@@ -140,6 +213,13 @@ function formatTranscript(entries) {
|
|
|
140
213
|
* This is the primary path: it reuses whatever provider routes the user has
|
|
141
214
|
* already configured in DSH (deepseek-official, llm-pi-ai providers, etc.),
|
|
142
215
|
* including credentials resolved by the harness.
|
|
216
|
+
*
|
|
217
|
+
* Timeout semantics (root cause of "thinking cut mid-sentence, no body"): a
|
|
218
|
+
* long-reasoning model can spend the whole advisor budget on its thinking
|
|
219
|
+
* chain. When `timeoutMs` fires, the stream simply stops (it does not throw),
|
|
220
|
+
* which used to produce a silent empty body. We now detect the timeout and
|
|
221
|
+
* report `truncated` so the caller can surface it to the user instead of
|
|
222
|
+
* treating the reply as complete. A caller-initiated abort still propagates.
|
|
143
223
|
*/
|
|
144
224
|
async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutMs) {
|
|
145
225
|
const userText = formatTranscript(transcript);
|
|
@@ -148,26 +228,54 @@ async function callViaCtxLlm(ctx, advisor, transcript, signal, onDelta, timeoutM
|
|
|
148
228
|
type: "text",
|
|
149
229
|
text: userText
|
|
150
230
|
}],
|
|
151
|
-
source: {
|
|
152
|
-
kind: "plugin",
|
|
153
|
-
plugin: "dsh-advisor-group"
|
|
154
|
-
}
|
|
231
|
+
source: { kind: "user" }
|
|
155
232
|
})];
|
|
233
|
+
const pair = timeoutSignalPair(timeoutMs ?? 12e4, signal);
|
|
234
|
+
const reasoningEffort = advisor.thinking === "disabled" ? "off" : advisor.reasoningEffort;
|
|
156
235
|
const options = {
|
|
157
236
|
provider: advisor.provider,
|
|
158
237
|
model: advisor.model,
|
|
159
238
|
system: `${advisor.systemPrompt}${ADVISOR_OUTPUT_POLICY}`,
|
|
160
239
|
messages,
|
|
240
|
+
...reasoningEffort === void 0 ? {} : { reasoningEffort },
|
|
161
241
|
temperature: advisor.temperature,
|
|
162
|
-
maxTokens: advisor.maxTokens,
|
|
163
|
-
signal:
|
|
242
|
+
maxTokens: advisor.maxTokens ?? 16384,
|
|
243
|
+
signal: pair.signal
|
|
164
244
|
};
|
|
165
245
|
let text = "";
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
246
|
+
let thinking = "";
|
|
247
|
+
try {
|
|
248
|
+
for await (const chunk of ctx.llm.stream(options)) if (chunk.type === "text-delta") {
|
|
249
|
+
text += chunk.text;
|
|
250
|
+
onDelta?.({ text: chunk.text });
|
|
251
|
+
} else if (chunk.type === "reasoning-delta") {
|
|
252
|
+
thinking += chunk.text;
|
|
253
|
+
onDelta?.({ thinking: chunk.text });
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (pair.isTimeout()) return {
|
|
257
|
+
content: text,
|
|
258
|
+
thinking,
|
|
259
|
+
truncated: {
|
|
260
|
+
reason: "timeout",
|
|
261
|
+
atMs: Date.now()
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
if (pair.isTimeout()) return {
|
|
267
|
+
content: text,
|
|
268
|
+
thinking,
|
|
269
|
+
truncated: {
|
|
270
|
+
reason: "timeout",
|
|
271
|
+
atMs: Date.now()
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
275
|
+
return {
|
|
276
|
+
content: text,
|
|
277
|
+
thinking
|
|
278
|
+
};
|
|
171
279
|
}
|
|
172
280
|
//#endregion
|
|
173
281
|
//#region src/providers/presets.ts
|
|
@@ -178,7 +286,11 @@ const PROVIDER_PRESETS = {
|
|
|
178
286
|
protocol: "openai",
|
|
179
287
|
baseURL: "https://api.deepseek.com",
|
|
180
288
|
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
181
|
-
defaultModels: [
|
|
289
|
+
defaultModels: [
|
|
290
|
+
"deepseek-v4-flash",
|
|
291
|
+
"deepseek-v4-pro",
|
|
292
|
+
"deepseek-v4-flash-vision-exp"
|
|
293
|
+
],
|
|
182
294
|
notes: "Also available as deepseek-official via dsh-llm-deepseek, and Anthropic-compatible at https://api.deepseek.com/anthropic."
|
|
183
295
|
},
|
|
184
296
|
"deepseek-anthropic": {
|
|
@@ -187,7 +299,11 @@ const PROVIDER_PRESETS = {
|
|
|
187
299
|
protocol: "anthropic",
|
|
188
300
|
baseURL: "https://api.deepseek.com/anthropic",
|
|
189
301
|
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
190
|
-
defaultModels: [
|
|
302
|
+
defaultModels: [
|
|
303
|
+
"deepseek-v4-flash",
|
|
304
|
+
"deepseek-v4-pro",
|
|
305
|
+
"deepseek-v4-flash-vision-exp"
|
|
306
|
+
],
|
|
191
307
|
authMode: "x-api-key",
|
|
192
308
|
modelsBaseURL: "https://api.deepseek.com",
|
|
193
309
|
modelsAuthMode: "bearer",
|
|
@@ -199,7 +315,12 @@ const PROVIDER_PRESETS = {
|
|
|
199
315
|
protocol: "openai",
|
|
200
316
|
baseURL: "https://api.moonshot.cn/v1",
|
|
201
317
|
apiKeyEnv: "MOONSHOT_API_KEY",
|
|
202
|
-
defaultModels: [
|
|
318
|
+
defaultModels: [
|
|
319
|
+
"kimi-k3",
|
|
320
|
+
"kimi-k2.7-code",
|
|
321
|
+
"kimi-k2.7-code-highspeed",
|
|
322
|
+
"kimi-k2.6"
|
|
323
|
+
],
|
|
203
324
|
notes: "OpenAI-compatible; Anthropic-compatible endpoint at https://api.moonshot.cn/anthropic"
|
|
204
325
|
},
|
|
205
326
|
"moonshot-anthropic": {
|
|
@@ -208,7 +329,12 @@ const PROVIDER_PRESETS = {
|
|
|
208
329
|
protocol: "anthropic",
|
|
209
330
|
baseURL: "https://api.moonshot.cn/anthropic",
|
|
210
331
|
apiKeyEnv: "MOONSHOT_API_KEY",
|
|
211
|
-
defaultModels: [
|
|
332
|
+
defaultModels: [
|
|
333
|
+
"kimi-k3",
|
|
334
|
+
"kimi-k2.7-code",
|
|
335
|
+
"kimi-k2.7-code-highspeed",
|
|
336
|
+
"kimi-k2.6"
|
|
337
|
+
],
|
|
212
338
|
authMode: "bearer",
|
|
213
339
|
modelsBaseURL: "https://api.moonshot.cn/v1",
|
|
214
340
|
modelsAuthMode: "bearer",
|
|
@@ -221,14 +347,14 @@ const PROVIDER_PRESETS = {
|
|
|
221
347
|
baseURL: "https://api.kimi.com/coding/v1",
|
|
222
348
|
apiKeyEnv: "KIMI_CODE_API_KEY",
|
|
223
349
|
defaultModels: [
|
|
224
|
-
"k3",
|
|
225
|
-
"
|
|
226
|
-
"kimi-
|
|
227
|
-
"kimi-for-coding
|
|
350
|
+
"kimi-k3",
|
|
351
|
+
"kimi-k2.7-code",
|
|
352
|
+
"kimi-k2.7-code-highspeed",
|
|
353
|
+
"kimi-for-coding"
|
|
228
354
|
],
|
|
229
355
|
modelsBaseURL: "https://api.kimi.com/coding/v1",
|
|
230
356
|
modelsAuthMode: "bearer",
|
|
231
|
-
notes: "Kimi Code 会员 API
|
|
357
|
+
notes: "Kimi Code 会员 API;可用模型以会员页为准,常用 kimi-k3 / kimi-k2.7-code / kimi-k2.7-code-highspeed,旧 kimi-for-coding 仍兼容。"
|
|
232
358
|
},
|
|
233
359
|
"kimi-code-anthropic": {
|
|
234
360
|
id: "kimi-code-anthropic",
|
|
@@ -237,10 +363,10 @@ const PROVIDER_PRESETS = {
|
|
|
237
363
|
baseURL: "https://api.kimi.com/coding",
|
|
238
364
|
apiKeyEnv: "KIMI_CODE_API_KEY",
|
|
239
365
|
defaultModels: [
|
|
240
|
-
"k3",
|
|
241
|
-
"
|
|
242
|
-
"kimi-
|
|
243
|
-
"kimi-for-coding
|
|
366
|
+
"kimi-k3",
|
|
367
|
+
"kimi-k2.7-code",
|
|
368
|
+
"kimi-k2.7-code-highspeed",
|
|
369
|
+
"kimi-for-coding"
|
|
244
370
|
],
|
|
245
371
|
authMode: "bearer",
|
|
246
372
|
modelsBaseURL: "https://api.kimi.com/coding/v1",
|
|
@@ -254,9 +380,9 @@ const PROVIDER_PRESETS = {
|
|
|
254
380
|
baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
255
381
|
apiKeyEnv: "DASHSCOPE_API_KEY",
|
|
256
382
|
defaultModels: [
|
|
257
|
-
"qwen3.
|
|
258
|
-
"qwen3.
|
|
259
|
-
"
|
|
383
|
+
"qwen3.8-max",
|
|
384
|
+
"qwen3.8-flash",
|
|
385
|
+
"qwen3.7-plus"
|
|
260
386
|
],
|
|
261
387
|
notes: "DashScope OpenAI-compatible endpoint(北京公共域名)。"
|
|
262
388
|
},
|
|
@@ -267,9 +393,9 @@ const PROVIDER_PRESETS = {
|
|
|
267
393
|
baseURL: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
|
268
394
|
apiKeyEnv: "DASHSCOPE_API_KEY",
|
|
269
395
|
defaultModels: [
|
|
270
|
-
"qwen3.
|
|
271
|
-
"qwen3.
|
|
272
|
-
"
|
|
396
|
+
"qwen3.8-max",
|
|
397
|
+
"qwen3.8-flash",
|
|
398
|
+
"qwen3.7-plus"
|
|
273
399
|
],
|
|
274
400
|
notes: "DashScope 新加坡公共域名 OpenAI 兼容端点。"
|
|
275
401
|
},
|
|
@@ -280,9 +406,9 @@ const PROVIDER_PRESETS = {
|
|
|
280
406
|
baseURL: "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
|
|
281
407
|
apiKeyEnv: "DASHSCOPE_API_KEY",
|
|
282
408
|
defaultModels: [
|
|
283
|
-
"qwen3.
|
|
284
|
-
"qwen3.
|
|
285
|
-
"
|
|
409
|
+
"qwen3.8-max",
|
|
410
|
+
"qwen3.8-flash",
|
|
411
|
+
"qwen3.7-plus"
|
|
286
412
|
],
|
|
287
413
|
notes: "DashScope 美国(弗吉尼亚)公共域名 OpenAI 兼容端点。"
|
|
288
414
|
},
|
|
@@ -293,13 +419,13 @@ const PROVIDER_PRESETS = {
|
|
|
293
419
|
baseURL: "https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/apps/anthropic",
|
|
294
420
|
apiKeyEnv: "DASHSCOPE_API_KEY",
|
|
295
421
|
defaultModels: [
|
|
296
|
-
"qwen3.
|
|
422
|
+
"qwen3.8-max",
|
|
423
|
+
"qwen3.8-flash",
|
|
297
424
|
"qwen3.7-plus",
|
|
298
|
-
"qwen3.6-plus",
|
|
299
425
|
"deepseek-v4-pro",
|
|
300
426
|
"deepseek-v4-flash",
|
|
301
|
-
"kimi-
|
|
302
|
-
"glm-5.
|
|
427
|
+
"kimi-k3",
|
|
428
|
+
"glm-5.3"
|
|
303
429
|
],
|
|
304
430
|
authMode: "bearer",
|
|
305
431
|
modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
@@ -313,13 +439,13 @@ const PROVIDER_PRESETS = {
|
|
|
313
439
|
baseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
|
|
314
440
|
apiKeyEnv: "DASHSCOPE_API_KEY",
|
|
315
441
|
defaultModels: [
|
|
316
|
-
"qwen3.
|
|
442
|
+
"qwen3.8-max",
|
|
443
|
+
"qwen3.8-flash",
|
|
317
444
|
"qwen3.7-plus",
|
|
318
|
-
"qwen3.6-plus",
|
|
319
445
|
"deepseek-v4-pro",
|
|
320
446
|
"deepseek-v4-flash",
|
|
321
|
-
"kimi-
|
|
322
|
-
"glm-5.
|
|
447
|
+
"kimi-k3",
|
|
448
|
+
"glm-5.3"
|
|
323
449
|
],
|
|
324
450
|
authMode: "bearer",
|
|
325
451
|
modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
@@ -333,13 +459,13 @@ const PROVIDER_PRESETS = {
|
|
|
333
459
|
baseURL: "https://dashscope-intl.aliyuncs.com/apps/anthropic",
|
|
334
460
|
apiKeyEnv: "DASHSCOPE_API_KEY",
|
|
335
461
|
defaultModels: [
|
|
336
|
-
"qwen3.
|
|
462
|
+
"qwen3.8-max",
|
|
463
|
+
"qwen3.8-flash",
|
|
337
464
|
"qwen3.7-plus",
|
|
338
|
-
"qwen3.6-plus",
|
|
339
465
|
"deepseek-v4-pro",
|
|
340
466
|
"deepseek-v4-flash",
|
|
341
|
-
"kimi-
|
|
342
|
-
"glm-5.
|
|
467
|
+
"kimi-k3",
|
|
468
|
+
"glm-5.3"
|
|
343
469
|
],
|
|
344
470
|
authMode: "bearer",
|
|
345
471
|
modelsBaseURL: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
|
@@ -353,13 +479,13 @@ const PROVIDER_PRESETS = {
|
|
|
353
479
|
baseURL: "https://dashscope-us.aliyuncs.com/apps/anthropic",
|
|
354
480
|
apiKeyEnv: "DASHSCOPE_API_KEY",
|
|
355
481
|
defaultModels: [
|
|
356
|
-
"qwen3.
|
|
482
|
+
"qwen3.8-max",
|
|
483
|
+
"qwen3.8-flash",
|
|
357
484
|
"qwen3.7-plus",
|
|
358
|
-
"qwen3.6-plus",
|
|
359
485
|
"deepseek-v4-pro",
|
|
360
486
|
"deepseek-v4-flash",
|
|
361
|
-
"kimi-
|
|
362
|
-
"glm-5.
|
|
487
|
+
"kimi-k3",
|
|
488
|
+
"glm-5.3"
|
|
363
489
|
],
|
|
364
490
|
authMode: "bearer",
|
|
365
491
|
modelsBaseURL: "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
|
|
@@ -373,13 +499,13 @@ const PROVIDER_PRESETS = {
|
|
|
373
499
|
baseURL: "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic",
|
|
374
500
|
apiKeyEnv: "DASHSCOPE_TOKEN_PLAN_API_KEY",
|
|
375
501
|
defaultModels: [
|
|
376
|
-
"qwen3.
|
|
502
|
+
"qwen3.8-max",
|
|
503
|
+
"qwen3.8-flash",
|
|
377
504
|
"qwen3.7-plus",
|
|
378
|
-
"qwen3.6-plus",
|
|
379
505
|
"deepseek-v4-pro",
|
|
380
506
|
"deepseek-v4-flash",
|
|
381
|
-
"kimi-
|
|
382
|
-
"glm-5.
|
|
507
|
+
"kimi-k3",
|
|
508
|
+
"glm-5.3"
|
|
383
509
|
],
|
|
384
510
|
authMode: "bearer",
|
|
385
511
|
modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
@@ -393,13 +519,13 @@ const PROVIDER_PRESETS = {
|
|
|
393
519
|
baseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
|
394
520
|
apiKeyEnv: "DASHSCOPE_CODING_PLAN_API_KEY",
|
|
395
521
|
defaultModels: [
|
|
396
|
-
"qwen3.
|
|
522
|
+
"qwen3.8-max",
|
|
523
|
+
"qwen3.8-flash",
|
|
397
524
|
"qwen3.7-plus",
|
|
398
|
-
"qwen3.6-plus",
|
|
399
525
|
"deepseek-v4-pro",
|
|
400
526
|
"deepseek-v4-flash",
|
|
401
|
-
"kimi-
|
|
402
|
-
"glm-5.
|
|
527
|
+
"kimi-k3",
|
|
528
|
+
"glm-5.3"
|
|
403
529
|
],
|
|
404
530
|
authMode: "bearer",
|
|
405
531
|
modelsBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
@@ -414,12 +540,9 @@ const PROVIDER_PRESETS = {
|
|
|
414
540
|
apiKeyEnv: "ZHIPU_API_KEY",
|
|
415
541
|
defaultModels: [
|
|
416
542
|
"glm-5.3",
|
|
543
|
+
"glm-5.3-flash",
|
|
417
544
|
"glm-5.2",
|
|
418
|
-
"glm-5"
|
|
419
|
-
"glm-4.6",
|
|
420
|
-
"glm-4.6v",
|
|
421
|
-
"glm-4.6v-flashx",
|
|
422
|
-
"glm-4.5v"
|
|
545
|
+
"glm-5.1"
|
|
423
546
|
],
|
|
424
547
|
modelsBaseURL: "https://open.bigmodel.cn/api/paas/v4",
|
|
425
548
|
modelsAuthMode: "bearer",
|
|
@@ -433,12 +556,9 @@ const PROVIDER_PRESETS = {
|
|
|
433
556
|
apiKeyEnv: "ZHIPU_API_KEY",
|
|
434
557
|
defaultModels: [
|
|
435
558
|
"glm-5.3",
|
|
559
|
+
"glm-5.3-flash",
|
|
436
560
|
"glm-5.2",
|
|
437
|
-
"glm-5"
|
|
438
|
-
"glm-4.6",
|
|
439
|
-
"glm-4.6v",
|
|
440
|
-
"glm-4.6v-flashx",
|
|
441
|
-
"glm-4.5v"
|
|
561
|
+
"glm-5.1"
|
|
442
562
|
],
|
|
443
563
|
authMode: "bearer",
|
|
444
564
|
modelsBaseURL: "https://open.bigmodel.cn/api/paas/v4",
|
|
@@ -451,7 +571,14 @@ const PROVIDER_PRESETS = {
|
|
|
451
571
|
protocol: "openai",
|
|
452
572
|
baseURL: "https://api.openai.com/v1",
|
|
453
573
|
apiKeyEnv: "OPENAI_API_KEY",
|
|
454
|
-
defaultModels: [
|
|
574
|
+
defaultModels: [
|
|
575
|
+
"gpt-6-astra",
|
|
576
|
+
"gpt-5.6-sol",
|
|
577
|
+
"gpt-5.6-terra",
|
|
578
|
+
"gpt-5.6-luna",
|
|
579
|
+
"gpt-5.5",
|
|
580
|
+
"gpt-5.4"
|
|
581
|
+
],
|
|
455
582
|
notes: "Official OpenAI API."
|
|
456
583
|
},
|
|
457
584
|
anthropic: {
|
|
@@ -460,7 +587,13 @@ const PROVIDER_PRESETS = {
|
|
|
460
587
|
protocol: "anthropic",
|
|
461
588
|
baseURL: "https://api.anthropic.com",
|
|
462
589
|
apiKeyEnv: "ANTHROPIC_API_KEY",
|
|
463
|
-
defaultModels: [
|
|
590
|
+
defaultModels: [
|
|
591
|
+
"claude-opus-5",
|
|
592
|
+
"claude-sonnet-5",
|
|
593
|
+
"claude-opus-4-8",
|
|
594
|
+
"claude-opus-4-7",
|
|
595
|
+
"claude-sonnet-4-6"
|
|
596
|
+
],
|
|
464
597
|
authMode: "x-api-key",
|
|
465
598
|
notes: "Anthropic Messages API; docs in D:/X/X/14-claude."
|
|
466
599
|
},
|
|
@@ -470,7 +603,12 @@ const PROVIDER_PRESETS = {
|
|
|
470
603
|
protocol: "gemini",
|
|
471
604
|
baseURL: "https://generativelanguage.googleapis.com",
|
|
472
605
|
apiKeyEnv: "GEMINI_API_KEY",
|
|
473
|
-
defaultModels: [
|
|
606
|
+
defaultModels: [
|
|
607
|
+
"gemini-3.8-flash",
|
|
608
|
+
"gemini-3.7-flash",
|
|
609
|
+
"gemini-3.1-pro-preview",
|
|
610
|
+
"gemini-3-flash-preview"
|
|
611
|
+
],
|
|
474
612
|
notes: "Google Gemini API; docs in D:/X/X/13-gemini."
|
|
475
613
|
},
|
|
476
614
|
siliconflow: {
|
|
@@ -479,7 +617,11 @@ const PROVIDER_PRESETS = {
|
|
|
479
617
|
protocol: "openai",
|
|
480
618
|
baseURL: "https://api.siliconflow.cn/v1",
|
|
481
619
|
apiKeyEnv: "SILICONFLOW_API_KEY",
|
|
482
|
-
defaultModels: [
|
|
620
|
+
defaultModels: [
|
|
621
|
+
"deepseek-ai/DeepSeek-V3.2",
|
|
622
|
+
"Qwen/Qwen3-235B-A22B",
|
|
623
|
+
"zai-org/GLM-5.1"
|
|
624
|
+
],
|
|
483
625
|
notes: "OpenAI-compatible; also supports Anthropic-compatible at /v1/messages."
|
|
484
626
|
},
|
|
485
627
|
"siliconflow-anthropic": {
|
|
@@ -490,8 +632,8 @@ const PROVIDER_PRESETS = {
|
|
|
490
632
|
apiKeyEnv: "SILICONFLOW_API_KEY",
|
|
491
633
|
defaultModels: [
|
|
492
634
|
"deepseek-ai/DeepSeek-V3.2",
|
|
493
|
-
"
|
|
494
|
-
"
|
|
635
|
+
"Qwen/Qwen3-32B",
|
|
636
|
+
"zai-org/GLM-5.1"
|
|
495
637
|
],
|
|
496
638
|
authMode: "bearer",
|
|
497
639
|
modelsBaseURL: "https://api.siliconflow.cn/v1",
|
|
@@ -505,8 +647,9 @@ const PROVIDER_PRESETS = {
|
|
|
505
647
|
baseURL: "https://aihubmix.com/v1",
|
|
506
648
|
apiKeyEnv: "AIHUBMIX_API_KEY",
|
|
507
649
|
defaultModels: [
|
|
508
|
-
"gpt-5",
|
|
650
|
+
"gpt-5.2",
|
|
509
651
|
"claude-sonnet-4-5",
|
|
652
|
+
"claude-opus-4-5",
|
|
510
653
|
"gemini-3-flash-preview"
|
|
511
654
|
],
|
|
512
655
|
notes: "Aggregator; also supports Anthropic-compatible at https://aihubmix.com."
|
|
@@ -519,8 +662,8 @@ const PROVIDER_PRESETS = {
|
|
|
519
662
|
apiKeyEnv: "AIHUBMIX_API_KEY",
|
|
520
663
|
defaultModels: [
|
|
521
664
|
"claude-sonnet-4-5",
|
|
522
|
-
"claude-opus-4-
|
|
523
|
-
"
|
|
665
|
+
"claude-opus-4-6",
|
|
666
|
+
"claude-opus-4-5"
|
|
524
667
|
],
|
|
525
668
|
authMode: "x-api-key",
|
|
526
669
|
modelsBaseURL: "https://aihubmix.com/v1",
|
|
@@ -534,8 +677,9 @@ const PROVIDER_PRESETS = {
|
|
|
534
677
|
baseURL: "https://openrouter.ai/api/v1",
|
|
535
678
|
apiKeyEnv: "OPENROUTER_API_KEY",
|
|
536
679
|
defaultModels: [
|
|
537
|
-
"openai/gpt-5",
|
|
538
|
-
"
|
|
680
|
+
"openai/gpt-5.6",
|
|
681
|
+
"openai/gpt-5.5",
|
|
682
|
+
"anthropic/claude-sonnet-4.6",
|
|
539
683
|
"deepseek/deepseek-chat"
|
|
540
684
|
],
|
|
541
685
|
notes: "OpenAI-compatible; docs in D:/X/X/06-OpenRouter."
|
|
@@ -547,9 +691,9 @@ const PROVIDER_PRESETS = {
|
|
|
547
691
|
baseURL: "https://openrouter.ai/api",
|
|
548
692
|
apiKeyEnv: "OPENROUTER_API_KEY",
|
|
549
693
|
defaultModels: [
|
|
550
|
-
"anthropic/claude-sonnet-4.
|
|
551
|
-
"anthropic/claude-opus-4-
|
|
552
|
-
"openai/gpt-5",
|
|
694
|
+
"anthropic/claude-sonnet-4.6",
|
|
695
|
+
"anthropic/claude-opus-4-6",
|
|
696
|
+
"openai/gpt-5.6",
|
|
553
697
|
"deepseek/deepseek-chat"
|
|
554
698
|
],
|
|
555
699
|
authMode: "bearer",
|
|
@@ -562,52 +706,282 @@ function getProviderPreset(id) {
|
|
|
562
706
|
return PROVIDER_PRESETS[id];
|
|
563
707
|
}
|
|
564
708
|
//#endregion
|
|
709
|
+
//#region src/dsml.ts
|
|
710
|
+
const OUTER_OPEN = /<[||]{0,2}(?:DSML[||]{0,2})?(?:tool_calls|function_calls)\s*>/g;
|
|
711
|
+
const OUTER_CLOSE_RE = /<\/[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}(?:tool_calls|function_calls)\s*>/;
|
|
712
|
+
const INVOKE_RE = /<[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}\s*invoke\s+name="([^"]+)">([\s\S]*?)<\/[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}\s*invoke\s*>/g;
|
|
713
|
+
const PARAM_RE = /<[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}\s*parameter\s+name="([^"]+)"(?:\s+string="true")?>([\s\S]*?)<\/[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}\s*parameter\s*>/g;
|
|
714
|
+
const PARAM_OPEN_RE = /<[||]{0,2}(?:DSML[||]{0,2})?[||]{0,2}\s*parameter\s+name="[^"]+"/g;
|
|
715
|
+
function insideFence(content, index) {
|
|
716
|
+
const fences = content.slice(0, index).match(/```/g);
|
|
717
|
+
return Boolean(fences && fences.length % 2 === 1);
|
|
718
|
+
}
|
|
719
|
+
/** Parse every invoke inside one outer block; null when any gate fails. */
|
|
720
|
+
function parseOuterBlock(block, allowedNames) {
|
|
721
|
+
const calls = [];
|
|
722
|
+
INVOKE_RE.lastIndex = 0;
|
|
723
|
+
let invoke;
|
|
724
|
+
let invokeCount = 0;
|
|
725
|
+
while ((invoke = INVOKE_RE.exec(block)) !== null) {
|
|
726
|
+
invokeCount += 1;
|
|
727
|
+
const name = invoke[1];
|
|
728
|
+
if (!allowedNames.has(name)) return null;
|
|
729
|
+
const body = invoke[2];
|
|
730
|
+
const params = {};
|
|
731
|
+
PARAM_RE.lastIndex = 0;
|
|
732
|
+
let param;
|
|
733
|
+
let paramCount = 0;
|
|
734
|
+
while ((param = PARAM_RE.exec(body)) !== null) {
|
|
735
|
+
paramCount += 1;
|
|
736
|
+
if (PARAM_OPEN_RE.test(param[2])) return null;
|
|
737
|
+
params[param[1]] = param[2];
|
|
738
|
+
PARAM_OPEN_RE.lastIndex = 0;
|
|
739
|
+
}
|
|
740
|
+
if ((body.match(PARAM_OPEN_RE) ?? []).length !== paramCount) return null;
|
|
741
|
+
PARAM_OPEN_RE.lastIndex = 0;
|
|
742
|
+
calls.push({
|
|
743
|
+
id: `text-${calls.length}`,
|
|
744
|
+
name,
|
|
745
|
+
argumentsJson: paramCount > 0 ? JSON.stringify(params) : "{}"
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
if (invokeCount === 0) return null;
|
|
749
|
+
return calls;
|
|
750
|
+
}
|
|
751
|
+
/** Extract text tool calls; blocks outside code fences, whitelisted names. */
|
|
752
|
+
function parseTextToolCalls(content, allowedNames) {
|
|
753
|
+
if (!content || allowedNames.size === 0) return {
|
|
754
|
+
toolCalls: [],
|
|
755
|
+
cleaned: content
|
|
756
|
+
};
|
|
757
|
+
const ranges = [];
|
|
758
|
+
const allCalls = [];
|
|
759
|
+
OUTER_OPEN.lastIndex = 0;
|
|
760
|
+
let match;
|
|
761
|
+
while ((match = OUTER_OPEN.exec(content)) !== null) {
|
|
762
|
+
const start = match.index;
|
|
763
|
+
if (insideFence(content, start)) continue;
|
|
764
|
+
const rest = content.slice(start);
|
|
765
|
+
const close = rest.match(OUTER_CLOSE_RE);
|
|
766
|
+
const calls = parseOuterBlock(close && close.index !== void 0 ? rest.slice(0, close.index) : rest, allowedNames);
|
|
767
|
+
if (calls === null) continue;
|
|
768
|
+
const end = close && close.index !== void 0 ? start + close.index + close[0].length : content.length;
|
|
769
|
+
allCalls.push(...calls);
|
|
770
|
+
ranges.push([start, end]);
|
|
771
|
+
}
|
|
772
|
+
if (allCalls.length === 0) return {
|
|
773
|
+
toolCalls: [],
|
|
774
|
+
cleaned: content
|
|
775
|
+
};
|
|
776
|
+
let cleaned = content;
|
|
777
|
+
for (let i = ranges.length - 1; i >= 0; i -= 1) {
|
|
778
|
+
const [from, to] = ranges[i];
|
|
779
|
+
cleaned = cleaned.slice(0, from) + cleaned.slice(to);
|
|
780
|
+
}
|
|
781
|
+
return {
|
|
782
|
+
toolCalls: allCalls,
|
|
783
|
+
cleaned: cleaned.trim()
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
//#endregion
|
|
565
787
|
//#region src/providers/direct-http.ts
|
|
788
|
+
/** True when the user asked to force thinking off, through either spelling. */
|
|
789
|
+
function thinkingOff(advisor) {
|
|
790
|
+
return advisor.thinking === "disabled" || advisor.reasoningEffort === "off";
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* OpenAI-compatible reasoning params, provider-aware (2026-09-05 知识 X 最新):
|
|
794
|
+
* - DeepSeek: `thinking {type}` switch + `reasoning_effort` low/high/max.
|
|
795
|
+
* - Kimi/Kimi Code: kimi-k3 = top-level `reasoning_effort` low/high/max (no
|
|
796
|
+
* `thinking`); kimi-k2.7-code = always thinking, no params; kimi-k2.6 =
|
|
797
|
+
* `thinking {type}` switch only.
|
|
798
|
+
* - OpenAI official: `reasoning_effort` minimal|low|medium|high (no max, no off).
|
|
799
|
+
* - 阿里云百炼: `enable_thinking` bool (Qwen chat completions).
|
|
800
|
+
* - 智谱: `thinking {type}` switch + `reasoning_effort` (GLM-5.3 force-enabled,
|
|
801
|
+
* disabled→enabled+low; GLM-5.2/5.1/5 can disable).
|
|
802
|
+
* - 硅基流动: `enable_thinking` + `thinking_budget`.
|
|
803
|
+
* - Others (AIHubMix/OpenRouter/custom): best-effort `reasoning_effort`.
|
|
804
|
+
*/
|
|
805
|
+
function openAiThinkingParams(advisor) {
|
|
806
|
+
const provider = advisor.provider;
|
|
807
|
+
const model = advisor.model;
|
|
808
|
+
const effort = advisor.reasoningEffort;
|
|
809
|
+
if (provider === "deepseek") {
|
|
810
|
+
if (thinkingOff(advisor)) return { thinking: { type: "disabled" } };
|
|
811
|
+
return {
|
|
812
|
+
...advisor.thinking === "enabled" ? { thinking: { type: "enabled" } } : {},
|
|
813
|
+
...effort && effort !== "off" ? { reasoning_effort: effort } : {}
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
if (provider === "moonshot" || provider === "kimi-code") {
|
|
817
|
+
if (/^(k3|kimi-k3)(-|$)/i.test(model)) {
|
|
818
|
+
if (effort && effort !== "off") return { reasoning_effort: effort };
|
|
819
|
+
return {};
|
|
820
|
+
}
|
|
821
|
+
if (/kimi-k2\.7-code|kimi-for-coding/i.test(model)) return {};
|
|
822
|
+
if (advisor.thinking === "disabled") return { thinking: { type: "disabled" } };
|
|
823
|
+
if (advisor.thinking === "enabled") return { thinking: { type: "enabled" } };
|
|
824
|
+
return {};
|
|
825
|
+
}
|
|
826
|
+
if (provider === "openai") {
|
|
827
|
+
if (effort === "low") return { reasoning_effort: "low" };
|
|
828
|
+
if (effort === "high" || effort === "max") return { reasoning_effort: "high" };
|
|
829
|
+
return {};
|
|
830
|
+
}
|
|
831
|
+
if (provider === "bailian" || provider === "bailian-openai-singapore" || provider === "bailian-openai-us") {
|
|
832
|
+
if (thinkingOff(advisor)) return { enable_thinking: false };
|
|
833
|
+
if (advisor.thinking === "enabled") return { enable_thinking: true };
|
|
834
|
+
return {};
|
|
835
|
+
}
|
|
836
|
+
if (provider === "zhipu") {
|
|
837
|
+
if (/glm-5\.3/i.test(model)) return {
|
|
838
|
+
thinking: { type: "enabled" },
|
|
839
|
+
reasoning_effort: thinkingOff(advisor) ? "low" : effort && effort !== "off" ? effort : "max"
|
|
840
|
+
};
|
|
841
|
+
if (/glm-5/i.test(model)) {
|
|
842
|
+
if (thinkingOff(advisor)) return { thinking: { type: "disabled" } };
|
|
843
|
+
return {
|
|
844
|
+
...advisor.thinking === "enabled" ? { thinking: { type: "enabled" } } : {},
|
|
845
|
+
...effort && effort !== "off" ? { reasoning_effort: effort } : {}
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
if (advisor.thinking === "disabled") return { thinking: { type: "disabled" } };
|
|
849
|
+
if (advisor.thinking === "enabled") return { thinking: { type: "enabled" } };
|
|
850
|
+
return {};
|
|
851
|
+
}
|
|
852
|
+
if (provider === "siliconflow") {
|
|
853
|
+
if (thinkingOff(advisor)) return { enable_thinking: false };
|
|
854
|
+
const budget = effort === "low" ? 1024 : effort === "high" ? 8192 : effort === "max" ? 16384 : 0;
|
|
855
|
+
return {
|
|
856
|
+
...advisor.thinking === "enabled" || budget > 0 ? { enable_thinking: true } : {},
|
|
857
|
+
...budget > 0 ? { thinking_budget: budget } : {}
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
if (effort && effort !== "off") return { reasoning_effort: effort };
|
|
861
|
+
return {};
|
|
862
|
+
}
|
|
863
|
+
/** Anthropic Messages reasoning params, provider-aware.
|
|
864
|
+
* Official Anthropic: adaptive (`thinking:{type:'adaptive'}` + `output_config.effort`)
|
|
865
|
+
* for Opus 4.6/4.7/Sonnet 4.6/Mythos; manual `enabled`+`budget_tokens` for older
|
|
866
|
+
* models. DeepSeek's Anthropic-compatible endpoint uses `reasoning.effort` +
|
|
867
|
+
* `output_config.effort` (its `budget_tokens` is ignored). Other Anthropic skins
|
|
868
|
+
* follow the official Anthropic wire shape. */
|
|
869
|
+
function anthropicThinkingParams(advisor) {
|
|
870
|
+
if (advisor.provider === "deepseek-anthropic") {
|
|
871
|
+
if (thinkingOff(advisor)) return { reasoning: { effort: "none" } };
|
|
872
|
+
if (advisor.reasoningEffort && advisor.reasoningEffort !== "off") return {
|
|
873
|
+
reasoning: { effort: advisor.reasoningEffort },
|
|
874
|
+
output_config: { effort: advisor.reasoningEffort }
|
|
875
|
+
};
|
|
876
|
+
return {};
|
|
877
|
+
}
|
|
878
|
+
if (thinkingOff(advisor)) return { thinking: { type: "disabled" } };
|
|
879
|
+
if (advisor.reasoningEffort && advisor.reasoningEffort !== "off") {
|
|
880
|
+
if (/(?:opus-4-[6-8]|opus-5|sonnet-4-6|sonnet-5|fable|mythos)/i.test(advisor.model)) return {
|
|
881
|
+
thinking: { type: "adaptive" },
|
|
882
|
+
output_config: { effort: advisor.reasoningEffort }
|
|
883
|
+
};
|
|
884
|
+
return { thinking: {
|
|
885
|
+
type: "enabled",
|
|
886
|
+
budget_tokens: advisor.reasoningEffort === "low" ? 2048 : advisor.reasoningEffort === "high" ? 8192 : 16384
|
|
887
|
+
} };
|
|
888
|
+
}
|
|
889
|
+
return {};
|
|
890
|
+
}
|
|
891
|
+
/** Gemini `generateContent` thinking config. Disabling thinking uses
|
|
892
|
+
* `thinkingBudget: 0` (NOT `includeThoughts:false`, which only hides returned
|
|
893
|
+
* thoughts). Effort uses `thinkingBudget`, the backward-compatible knob that
|
|
894
|
+
* works across Gemini 2.5 and Gemini 3 per Google's migration note. */
|
|
895
|
+
function geminiThinkingParams(advisor) {
|
|
896
|
+
if (thinkingOff(advisor)) return { thinkingConfig: { thinkingBudget: 0 } };
|
|
897
|
+
const budget = advisor.reasoningEffort === "low" ? 1024 : advisor.reasoningEffort === "high" ? 8192 : advisor.reasoningEffort === "max" ? 16384 : 0;
|
|
898
|
+
if (budget > 0) return { thinkingConfig: { thinkingBudget: budget } };
|
|
899
|
+
return {};
|
|
900
|
+
}
|
|
901
|
+
/** Validate the per-advisor thinking fields at the direct-http boundary so a
|
|
902
|
+
* malformed stored config fails loudly instead of silently ignoring. */
|
|
903
|
+
function assertThinkingParams(advisor) {
|
|
904
|
+
if (advisor.thinking !== void 0 && advisor.thinking !== "enabled" && advisor.thinking !== "disabled") throw new Error(`Unsupported thinking mode: ${String(advisor.thinking)}`);
|
|
905
|
+
if (advisor.reasoningEffort !== void 0 && ![
|
|
906
|
+
"off",
|
|
907
|
+
"low",
|
|
908
|
+
"high",
|
|
909
|
+
"max"
|
|
910
|
+
].includes(advisor.reasoningEffort)) throw new Error(`Unsupported reasoningEffort: ${String(advisor.reasoningEffort)}`);
|
|
911
|
+
}
|
|
566
912
|
/** Stream an advisor response through direct HTTP (OpenAI/Anthropic SSE). */
|
|
567
|
-
async function streamDirectHttp(advisor, transcript, onDelta, signal, timeoutMs) {
|
|
913
|
+
async function streamDirectHttp(advisor, transcript, onDelta, signal, timeoutMs, tools, parseToolNames) {
|
|
568
914
|
const preset = advisor.provider ? getProviderPreset(advisor.provider) : void 0;
|
|
569
915
|
const baseURL = (advisor.baseURL ?? preset?.baseURL ?? "").replace(/\/+$/, "");
|
|
570
916
|
const apiKey = advisor.apiKey ?? process.env[advisor.apiKeyEnv ?? preset?.apiKeyEnv ?? ""] ?? "";
|
|
571
917
|
const protocol = advisor.protocol ?? preset?.protocol ?? "openai";
|
|
572
918
|
const authMode = advisor.authMode ?? preset?.authMode ?? "x-api-key";
|
|
919
|
+
assertThinkingParams(advisor);
|
|
573
920
|
if (!baseURL || !apiKey) throw new Error(`Direct HTTP fallback is not configured for advisor "${advisor.name}" (${advisor.provider}). Add baseURL/apiKey or configure the provider in DSH.`);
|
|
574
921
|
const transcriptText = transcript.map((entry) => `[${entry.role === "main" ? "主模型" : entry.name}]\n${entry.content}`).join("\n\n");
|
|
922
|
+
let result;
|
|
575
923
|
switch (protocol) {
|
|
576
|
-
case "openai":
|
|
577
|
-
|
|
924
|
+
case "openai":
|
|
925
|
+
result = await streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs, tools);
|
|
926
|
+
break;
|
|
927
|
+
case "anthropic":
|
|
928
|
+
result = await streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMode, onDelta, signal, timeoutMs, tools);
|
|
929
|
+
break;
|
|
578
930
|
case "gemini": {
|
|
579
931
|
const content = await callGemini(baseURL, advisor, apiKey, transcriptText, signal, timeoutMs);
|
|
580
932
|
onDelta({ text: content });
|
|
581
|
-
|
|
933
|
+
result = {
|
|
582
934
|
content,
|
|
583
935
|
thinking: ""
|
|
584
936
|
};
|
|
937
|
+
break;
|
|
585
938
|
}
|
|
586
939
|
default: throw new Error(`Unsupported protocol: ${String(protocol)}`);
|
|
587
940
|
}
|
|
941
|
+
const parsed = parseTextToolCalls(result.content, new Set(parseToolNames ?? (tools ?? []).map((tool) => tool.name)));
|
|
942
|
+
if (parsed.toolCalls.length > 0) {
|
|
943
|
+
console.warn(`[dsh-advisor-group] 检测到文本工具调用(DSML/非结构流),已还原并交给 runAdvisorToolLoop 执行:${parsed.toolCalls.map((tool) => tool.name).join("、")}`);
|
|
944
|
+
return {
|
|
945
|
+
...result,
|
|
946
|
+
content: parsed.cleaned,
|
|
947
|
+
toolCalls: [...result.toolCalls ?? [], ...parsed.toolCalls]
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
return result;
|
|
588
951
|
}
|
|
589
|
-
async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs) {
|
|
952
|
+
async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText, onDelta, signal, timeoutMs, tools) {
|
|
590
953
|
const endpoint = baseURL.endsWith("/chat/completions") ? baseURL : `${baseURL.replace(/\/+$/, "")}/chat/completions`;
|
|
954
|
+
const pair = timeoutSignalPair(timeoutMs ?? 12e4, signal);
|
|
955
|
+
const body = {
|
|
956
|
+
model: advisor.model,
|
|
957
|
+
messages: [{
|
|
958
|
+
role: "system",
|
|
959
|
+
content: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY
|
|
960
|
+
}, {
|
|
961
|
+
role: "user",
|
|
962
|
+
content: transcriptText
|
|
963
|
+
}],
|
|
964
|
+
temperature: advisor.temperature ?? .3,
|
|
965
|
+
max_tokens: advisor.maxTokens ?? 16384,
|
|
966
|
+
...openAiThinkingParams(advisor),
|
|
967
|
+
stream: true
|
|
968
|
+
};
|
|
969
|
+
if (tools && tools.length > 0) body.tools = tools.map((tool) => ({
|
|
970
|
+
type: "function",
|
|
971
|
+
function: {
|
|
972
|
+
name: tool.name,
|
|
973
|
+
...tool.description === void 0 ? {} : { description: tool.description },
|
|
974
|
+
parameters: tool.parameters ?? { type: "object" }
|
|
975
|
+
}
|
|
976
|
+
}));
|
|
591
977
|
const response = await fetch(endpoint, {
|
|
592
978
|
method: "POST",
|
|
593
979
|
headers: {
|
|
594
980
|
"content-type": "application/json",
|
|
595
981
|
authorization: `Bearer ${apiKey}`
|
|
596
982
|
},
|
|
597
|
-
body: JSON.stringify(
|
|
598
|
-
|
|
599
|
-
messages: [{
|
|
600
|
-
role: "system",
|
|
601
|
-
content: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY
|
|
602
|
-
}, {
|
|
603
|
-
role: "user",
|
|
604
|
-
content: transcriptText
|
|
605
|
-
}],
|
|
606
|
-
temperature: advisor.temperature ?? .3,
|
|
607
|
-
max_tokens: advisor.maxTokens ?? 16384,
|
|
608
|
-
stream: true
|
|
609
|
-
}),
|
|
610
|
-
signal: withTimeout(timeoutMs ?? 12e4, signal)
|
|
983
|
+
body: JSON.stringify(body),
|
|
984
|
+
signal: pair.signal
|
|
611
985
|
});
|
|
612
986
|
if (!response.ok) throw new Error(`OpenAI-compatible request failed: ${response.status} ${await response.text()}`);
|
|
613
987
|
if (!response.body) throw new Error("OpenAI-compatible response has no body");
|
|
@@ -616,6 +990,7 @@ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText,
|
|
|
616
990
|
let buffer = "";
|
|
617
991
|
let content = "";
|
|
618
992
|
let thinking = "";
|
|
993
|
+
const toolCalls = /* @__PURE__ */ new Map();
|
|
619
994
|
while (true) {
|
|
620
995
|
let done;
|
|
621
996
|
let value;
|
|
@@ -624,8 +999,28 @@ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText,
|
|
|
624
999
|
done = read.done;
|
|
625
1000
|
value = read.value;
|
|
626
1001
|
} catch (error) {
|
|
1002
|
+
const errName = error?.name ?? "UnknownError";
|
|
1003
|
+
const errMessage = error instanceof Error ? error.message : String(error);
|
|
1004
|
+
console.warn(`[dsh-advisor-group] direct-http stream read error: advisor=${advisor.name} base=${baseURL} name=${errName} msg=${errMessage} isTimeout=${pair.isTimeout()} signalAborted=${signal?.aborted ?? false} collectedChars=${content.length + thinking.length}`);
|
|
1005
|
+
if (pair.isTimeout()) return {
|
|
1006
|
+
content,
|
|
1007
|
+
thinking,
|
|
1008
|
+
toolCalls: collectToolCalls(toolCalls),
|
|
1009
|
+
truncated: {
|
|
1010
|
+
reason: "timeout",
|
|
1011
|
+
atMs: Date.now()
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
627
1014
|
if (signal?.aborted || error?.name === "AbortError") throw error;
|
|
628
|
-
|
|
1015
|
+
return {
|
|
1016
|
+
content,
|
|
1017
|
+
thinking,
|
|
1018
|
+
toolCalls: collectToolCalls(toolCalls),
|
|
1019
|
+
truncated: {
|
|
1020
|
+
reason: "network",
|
|
1021
|
+
atMs: Date.now()
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
629
1024
|
}
|
|
630
1025
|
if (done) break;
|
|
631
1026
|
buffer += decoder.decode(value, { stream: true });
|
|
@@ -641,6 +1036,18 @@ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText,
|
|
|
641
1036
|
const delta = JSON.parse(data).choices?.[0]?.delta ?? {};
|
|
642
1037
|
const text = typeof delta.content === "string" ? delta.content : "";
|
|
643
1038
|
const think = typeof delta.reasoning_content === "string" ? delta.reasoning_content : typeof delta.reasoning === "string" ? delta.reasoning : "";
|
|
1039
|
+
if (delta.tool_calls) for (const tc of delta.tool_calls) {
|
|
1040
|
+
const idx = tc.index ?? 0;
|
|
1041
|
+
const current = toolCalls.get(idx) ?? {
|
|
1042
|
+
id: "",
|
|
1043
|
+
name: "",
|
|
1044
|
+
args: ""
|
|
1045
|
+
};
|
|
1046
|
+
if (tc.id) current.id = tc.id;
|
|
1047
|
+
if (tc.function?.name) current.name = tc.function.name;
|
|
1048
|
+
if (tc.function?.arguments) current.args += tc.function.arguments;
|
|
1049
|
+
toolCalls.set(idx, current);
|
|
1050
|
+
}
|
|
644
1051
|
if (text) {
|
|
645
1052
|
content += text;
|
|
646
1053
|
onDelta({ text });
|
|
@@ -655,10 +1062,24 @@ async function streamOpenAICompatible(baseURL, advisor, apiKey, transcriptText,
|
|
|
655
1062
|
}
|
|
656
1063
|
return {
|
|
657
1064
|
content,
|
|
658
|
-
thinking
|
|
1065
|
+
thinking,
|
|
1066
|
+
toolCalls: collectToolCalls(toolCalls)
|
|
659
1067
|
};
|
|
660
1068
|
}
|
|
661
|
-
|
|
1069
|
+
/** Map<index, {id,name,args}> → model-facing tool call list. */
|
|
1070
|
+
function collectToolCalls(byIndex) {
|
|
1071
|
+
const calls = [];
|
|
1072
|
+
for (const [, current] of byIndex) {
|
|
1073
|
+
if (!current.name) continue;
|
|
1074
|
+
calls.push({
|
|
1075
|
+
id: current.id || `tool-${calls.length}`,
|
|
1076
|
+
name: current.name,
|
|
1077
|
+
argumentsJson: current.args
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
return calls.length > 0 ? calls : void 0;
|
|
1081
|
+
}
|
|
1082
|
+
async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMode, onDelta, signal, timeoutMs, tools) {
|
|
662
1083
|
const endpoint = baseURL.endsWith("/v1/messages") ? baseURL : baseURL.endsWith("/v1") ? `${baseURL}/messages` : `${baseURL.replace(/\/+$/, "")}/v1/messages`;
|
|
663
1084
|
const headers = {
|
|
664
1085
|
"content-type": "application/json",
|
|
@@ -666,21 +1087,29 @@ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMod
|
|
|
666
1087
|
};
|
|
667
1088
|
if (authMode === "bearer") headers["authorization"] = `Bearer ${apiKey}`;
|
|
668
1089
|
else headers["x-api-key"] = apiKey;
|
|
1090
|
+
const body = {
|
|
1091
|
+
model: advisor.model,
|
|
1092
|
+
system: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY,
|
|
1093
|
+
messages: [{
|
|
1094
|
+
role: "user",
|
|
1095
|
+
content: transcriptText
|
|
1096
|
+
}],
|
|
1097
|
+
temperature: advisor.temperature ?? .3,
|
|
1098
|
+
max_tokens: advisor.maxTokens ?? 16384,
|
|
1099
|
+
...anthropicThinkingParams(advisor),
|
|
1100
|
+
stream: true
|
|
1101
|
+
};
|
|
1102
|
+
if (tools && tools.length > 0) body.tools = tools.map((tool) => ({
|
|
1103
|
+
name: tool.name,
|
|
1104
|
+
...tool.description === void 0 ? {} : { description: tool.description },
|
|
1105
|
+
input_schema: tool.parameters ?? { type: "object" }
|
|
1106
|
+
}));
|
|
1107
|
+
const pair = timeoutSignalPair(timeoutMs ?? 12e4, signal);
|
|
669
1108
|
const response = await fetch(endpoint, {
|
|
670
1109
|
method: "POST",
|
|
671
1110
|
headers,
|
|
672
|
-
body: JSON.stringify(
|
|
673
|
-
|
|
674
|
-
system: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY,
|
|
675
|
-
messages: [{
|
|
676
|
-
role: "user",
|
|
677
|
-
content: transcriptText
|
|
678
|
-
}],
|
|
679
|
-
temperature: advisor.temperature ?? .3,
|
|
680
|
-
max_tokens: advisor.maxTokens ?? 16384,
|
|
681
|
-
stream: true
|
|
682
|
-
}),
|
|
683
|
-
signal: withTimeout(timeoutMs ?? 12e4, signal)
|
|
1111
|
+
body: JSON.stringify(body),
|
|
1112
|
+
signal: pair.signal
|
|
684
1113
|
});
|
|
685
1114
|
if (!response.ok) throw new Error(`Anthropic request failed: ${response.status} ${await response.text()}`);
|
|
686
1115
|
if (!response.body) throw new Error("Anthropic response has no body");
|
|
@@ -689,6 +1118,9 @@ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMod
|
|
|
689
1118
|
let buffer = "";
|
|
690
1119
|
let content = "";
|
|
691
1120
|
let thinking = "";
|
|
1121
|
+
let currentTool = null;
|
|
1122
|
+
let activeTool = false;
|
|
1123
|
+
const toolCalls = [];
|
|
692
1124
|
while (true) {
|
|
693
1125
|
let done;
|
|
694
1126
|
let value;
|
|
@@ -697,8 +1129,28 @@ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMod
|
|
|
697
1129
|
done = read.done;
|
|
698
1130
|
value = read.value;
|
|
699
1131
|
} catch (error) {
|
|
1132
|
+
const errName = error?.name ?? "UnknownError";
|
|
1133
|
+
const errMessage = error instanceof Error ? error.message : String(error);
|
|
1134
|
+
console.warn(`[dsh-advisor-group] anthropic stream read error: advisor=${advisor.name} base=${baseURL} name=${errName} msg=${errMessage} isTimeout=${pair.isTimeout()} signalAborted=${signal?.aborted ?? false} collectedChars=${content.length + thinking.length}`);
|
|
1135
|
+
if (pair.isTimeout()) return {
|
|
1136
|
+
content,
|
|
1137
|
+
thinking,
|
|
1138
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
|
|
1139
|
+
truncated: {
|
|
1140
|
+
reason: "timeout",
|
|
1141
|
+
atMs: Date.now()
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
700
1144
|
if (signal?.aborted || error?.name === "AbortError") throw error;
|
|
701
|
-
|
|
1145
|
+
return {
|
|
1146
|
+
content,
|
|
1147
|
+
thinking,
|
|
1148
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
|
|
1149
|
+
truncated: {
|
|
1150
|
+
reason: "network",
|
|
1151
|
+
atMs: Date.now()
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
702
1154
|
}
|
|
703
1155
|
if (done) break;
|
|
704
1156
|
buffer += decoder.decode(value, { stream: true });
|
|
@@ -712,13 +1164,32 @@ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMod
|
|
|
712
1164
|
if (!data) continue;
|
|
713
1165
|
try {
|
|
714
1166
|
const json = JSON.parse(data);
|
|
715
|
-
if (json.type === "
|
|
1167
|
+
if (json.type === "content_block_start" && json.content_block?.type === "tool_use") {
|
|
1168
|
+
currentTool = currentTool === null ? {
|
|
1169
|
+
id: json.content_block.id ?? "",
|
|
1170
|
+
name: json.content_block.name ?? "",
|
|
1171
|
+
args: ""
|
|
1172
|
+
} : currentTool;
|
|
1173
|
+
activeTool = true;
|
|
1174
|
+
} else if (json.type === "content_block_delta" && json.delta) {
|
|
716
1175
|
if (json.delta.type === "text_delta" && typeof json.delta.text === "string") {
|
|
717
1176
|
content += json.delta.text;
|
|
718
1177
|
onDelta({ text: json.delta.text });
|
|
719
1178
|
} else if (json.delta.type === "thinking_delta" && typeof json.delta.thinking === "string") {
|
|
720
1179
|
thinking += json.delta.thinking;
|
|
721
1180
|
onDelta({ thinking: json.delta.thinking });
|
|
1181
|
+
} else if (json.delta.type === "input_json_delta" && typeof json.delta.partial_json === "string") {
|
|
1182
|
+
if (currentTool) currentTool.args += json.delta.partial_json;
|
|
1183
|
+
}
|
|
1184
|
+
} else if (json.type === "content_block_stop") {
|
|
1185
|
+
if (activeTool && currentTool) {
|
|
1186
|
+
toolCalls.push({
|
|
1187
|
+
id: currentTool.id,
|
|
1188
|
+
name: currentTool.name,
|
|
1189
|
+
argumentsJson: currentTool.args
|
|
1190
|
+
});
|
|
1191
|
+
currentTool = null;
|
|
1192
|
+
activeTool = false;
|
|
722
1193
|
}
|
|
723
1194
|
}
|
|
724
1195
|
} catch {}
|
|
@@ -727,7 +1198,8 @@ async function streamAnthropic(baseURL, advisor, apiKey, transcriptText, authMod
|
|
|
727
1198
|
}
|
|
728
1199
|
return {
|
|
729
1200
|
content,
|
|
730
|
-
thinking
|
|
1201
|
+
thinking,
|
|
1202
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : void 0
|
|
731
1203
|
};
|
|
732
1204
|
}
|
|
733
1205
|
async function callGemini(baseURL, advisor, apiKey, transcriptText, signal, timeoutMs) {
|
|
@@ -745,7 +1217,8 @@ async function callGemini(baseURL, advisor, apiKey, transcriptText, signal, time
|
|
|
745
1217
|
systemInstruction: { parts: [{ text: advisor.systemPrompt + ADVISOR_OUTPUT_POLICY }] },
|
|
746
1218
|
generationConfig: {
|
|
747
1219
|
temperature: advisor.temperature ?? .3,
|
|
748
|
-
maxOutputTokens: advisor.maxTokens ?? 16384
|
|
1220
|
+
maxOutputTokens: advisor.maxTokens ?? 16384,
|
|
1221
|
+
...geminiThinkingParams(advisor)
|
|
749
1222
|
}
|
|
750
1223
|
}),
|
|
751
1224
|
signal: withTimeout(timeoutMs ?? 12e4, signal)
|
|
@@ -764,7 +1237,7 @@ async function callGemini(baseURL, advisor, apiKey, transcriptText, signal, time
|
|
|
764
1237
|
* stalls. Any generation error degrades to the fallback text, never throws.
|
|
765
1238
|
*/
|
|
766
1239
|
const FALLBACK_DEEPEN_QUESTION = "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
|
|
767
|
-
const FALLBACK_CONCLUSION = "
|
|
1240
|
+
const FALLBACK_CONCLUSION = "(⚠ 驱动模型未能生成综合结论:请主模型以上述顾问讨论为基础,自行总结共识、分歧与最可靠的行动建议。)";
|
|
768
1241
|
const DRIVER_SYSTEM_PROMPT = `
|
|
769
1242
|
你是顾问群的主持人(驱动模型)。你的职责:
|
|
770
1243
|
1. 阅读项目背景、主模型问题与全部顾问回答;
|
|
@@ -795,7 +1268,7 @@ function transcriptOf(session) {
|
|
|
795
1268
|
return `[${message.role === "main" ? "主模型" : message.advisorName ?? message.advisorId ?? "顾问"}]\n${message.content}`;
|
|
796
1269
|
}).join("\n\n").slice(-24e3);
|
|
797
1270
|
}
|
|
798
|
-
async function generateWith(ctx, source, system, prompt, signal) {
|
|
1271
|
+
async function generateWith(ctx, source, sessionId, system, prompt, signal, timeoutMs = 6e5) {
|
|
799
1272
|
try {
|
|
800
1273
|
const options = {
|
|
801
1274
|
provider: source.provider,
|
|
@@ -806,31 +1279,56 @@ async function generateWith(ctx, source, system, prompt, signal) {
|
|
|
806
1279
|
type: "text",
|
|
807
1280
|
text: prompt
|
|
808
1281
|
}],
|
|
809
|
-
source: {
|
|
810
|
-
kind: "plugin",
|
|
811
|
-
plugin: "dsh-advisor-group"
|
|
812
|
-
}
|
|
1282
|
+
source: { kind: "user" }
|
|
813
1283
|
})],
|
|
814
1284
|
temperature: .7,
|
|
815
|
-
maxTokens:
|
|
816
|
-
signal: withTimeout(
|
|
1285
|
+
maxTokens: 2048,
|
|
1286
|
+
signal: withTimeout(timeoutMs, signal)
|
|
817
1287
|
};
|
|
818
1288
|
let text = "";
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1289
|
+
let chunks = 0;
|
|
1290
|
+
let textDeltas = 0;
|
|
1291
|
+
let reasoningDeltas = 0;
|
|
1292
|
+
for await (const chunk of ctx.llm.stream(options)) {
|
|
1293
|
+
chunks += 1;
|
|
1294
|
+
if (chunk.type === "text-delta") {
|
|
1295
|
+
textDeltas += 1;
|
|
1296
|
+
text += chunk.text;
|
|
1297
|
+
} else reasoningDeltas += 1;
|
|
1298
|
+
}
|
|
1299
|
+
const trimmed = text.trim();
|
|
1300
|
+
if (!trimmed) console.warn(`[dsh-advisor-group] 驱动模型空响应(${source.provider}/${source.model}):`, sessionId, `chunks=${chunks} textDeltas=${textDeltas} reasoningDeltas=${reasoningDeltas};建议配置 discussion.driverModel 或提高 maxTokens`);
|
|
1301
|
+
return trimmed || void 0;
|
|
1302
|
+
} catch (error) {
|
|
1303
|
+
if (signal?.aborted) throw error;
|
|
1304
|
+
console.warn(`[dsh-advisor-group] 驱动模型生成失败(${source.provider}/${source.model}):`, sessionId, error instanceof Error ? error.message : String(error));
|
|
822
1305
|
return;
|
|
823
1306
|
}
|
|
824
1307
|
}
|
|
825
1308
|
/** One deepening question for the next round (never throws; returns fallback text). */
|
|
826
|
-
async function generateDeepenQuestion(ctx, session, source, signal) {
|
|
1309
|
+
async function generateDeepenQuestion(ctx, session, source, signal, timeoutMs) {
|
|
827
1310
|
if (!source) return FALLBACK_DEEPEN_QUESTION;
|
|
828
|
-
return await generateWith(ctx, source, DRIVER_SYSTEM_PROMPT, `以下是一轮顾问群的讨论记录:\n\n${transcriptOf(session)}\n\n请给出下一步的深入追问。`, signal) || "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
|
|
1311
|
+
return await generateWith(ctx, source, session.id, DRIVER_SYSTEM_PROMPT, `以下是一轮顾问群的讨论记录:\n\n${transcriptOf(session)}\n\n请给出下一步的深入追问。`, signal, timeoutMs) || "请基于以上全部讨论,针对仍然存在的分歧、未验证的假设与主要风险,提出一个有深度的后续问题。";
|
|
829
1312
|
}
|
|
830
1313
|
/** Final synthesis after all rounds (never throws; returns fallback text). */
|
|
831
|
-
async function generateConclusion(ctx, session, source, signal) {
|
|
1314
|
+
async function generateConclusion(ctx, session, source, signal, timeoutMs) {
|
|
832
1315
|
if (!source) return FALLBACK_CONCLUSION;
|
|
833
|
-
return await generateWith(ctx, source, CONCLUSION_SYSTEM_PROMPT, `以下是顾问群全部 ${session.messages.filter((m) => m.role === "advisor").length} 条顾问回答的讨论记录:\n\n${transcriptOf(session)}\n\n请给出综合结论。`, signal) || "
|
|
1316
|
+
return await generateWith(ctx, source, session.id, CONCLUSION_SYSTEM_PROMPT, `以下是顾问群全部 ${session.messages.filter((m) => m.role === "advisor").length} 条顾问回答的讨论记录:\n\n${transcriptOf(session)}\n\n请给出综合结论。`, signal, timeoutMs) || "(⚠ 驱动模型未能生成综合结论:请主模型以上述顾问讨论为基础,自行总结共识、分歧与最可靠的行动建议。)";
|
|
1317
|
+
}
|
|
1318
|
+
//#endregion
|
|
1319
|
+
//#region src/risk-notes.ts
|
|
1320
|
+
const TRUNCATION_NOTE = "有顾问输出在流式过程中被截断(超时或网络中断),其正文可能不完整,且该顾问本轮可能只提供了部分意见。";
|
|
1321
|
+
const CANCEL_NOTE = "本次顾问群讨论已被用户或主模型取消,结论可能不完整。";
|
|
1322
|
+
/**
|
|
1323
|
+
* 汇总风险提示(两条规则:截断 / 取消)。
|
|
1324
|
+
* 判定只看消息上的截断标志与 cancelled 标志,**不读正文**——没有基于文本的猜测,
|
|
1325
|
+
* 因此不会误报。content 字段保留在入参类型里只为兼容既有调用方(本函数不再读它)。
|
|
1326
|
+
*/
|
|
1327
|
+
function buildRiskNotes(messages, cancelled) {
|
|
1328
|
+
const notes = [];
|
|
1329
|
+
if (messages.some((message) => message.truncated !== void 0)) notes.push(TRUNCATION_NOTE);
|
|
1330
|
+
if (cancelled) notes.push(CANCEL_NOTE);
|
|
1331
|
+
return notes;
|
|
834
1332
|
}
|
|
835
1333
|
//#endregion
|
|
836
1334
|
//#region src/session-log.ts
|
|
@@ -889,7 +1387,11 @@ function appendAdvisorMessage(log, sessionId, message) {
|
|
|
889
1387
|
...message.advisorId === void 0 ? {} : { advisorId: message.advisorId },
|
|
890
1388
|
...message.advisorName === void 0 ? {} : { advisorName: message.advisorName },
|
|
891
1389
|
...message.round === void 0 ? {} : { round: message.round },
|
|
892
|
-
...message.thinking === void 0 ? {} : { thinking: message.thinking }
|
|
1390
|
+
...message.thinking === void 0 ? {} : { thinking: message.thinking },
|
|
1391
|
+
...message.thinkingSegments === void 0 ? {} : { thinkingSegments: message.thinkingSegments },
|
|
1392
|
+
...message.actionDescriptions === void 0 ? {} : { actionDescriptions: message.actionDescriptions },
|
|
1393
|
+
...message.toolSteps === void 0 ? {} : { toolSteps: message.toolSteps },
|
|
1394
|
+
...message.truncated === void 0 ? {} : { truncated: message.truncated }
|
|
893
1395
|
};
|
|
894
1396
|
log.append("advisor-group/message", data);
|
|
895
1397
|
}
|
|
@@ -904,10 +1406,19 @@ function appendAdvisorDelta(log, sessionId, delta) {
|
|
|
904
1406
|
...delta.round === void 0 ? {} : { round: delta.round },
|
|
905
1407
|
...delta.contentDelta === void 0 ? {} : { contentDelta: delta.contentDelta },
|
|
906
1408
|
...delta.thinkingDelta === void 0 ? {} : { thinkingDelta: delta.thinkingDelta },
|
|
1409
|
+
...delta.toolStep === void 0 ? {} : { toolStep: delta.toolStep },
|
|
907
1410
|
...delta.done === void 0 ? {} : { done: delta.done }
|
|
908
1411
|
};
|
|
909
1412
|
log.append("advisor-group/delta", data);
|
|
910
1413
|
}
|
|
1414
|
+
function appendAdvisorResume(log, sessionId) {
|
|
1415
|
+
const { turn, step } = latestTurnStep(log);
|
|
1416
|
+
log.append("advisor-group/resume", {
|
|
1417
|
+
sessionId,
|
|
1418
|
+
turn,
|
|
1419
|
+
step
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
911
1422
|
function appendAdvisorEnd(log, session, summary) {
|
|
912
1423
|
const { turn, step } = latestTurnStep(log);
|
|
913
1424
|
const data = {
|
|
@@ -921,11 +1432,68 @@ function appendAdvisorEnd(log, session, summary) {
|
|
|
921
1432
|
advisorName,
|
|
922
1433
|
corePoints
|
|
923
1434
|
})),
|
|
924
|
-
...summary.riskNotes.length > 0 ? { riskNotes: summary.riskNotes } : {}
|
|
1435
|
+
...summary.riskNotes.length > 0 ? { riskNotes: summary.riskNotes } : {},
|
|
1436
|
+
...summary.stopped === void 0 ? {} : { stopped: summary.stopped },
|
|
1437
|
+
...summary.conclusion === void 0 ? {} : { conclusion: summary.conclusion }
|
|
925
1438
|
}
|
|
926
1439
|
};
|
|
927
1440
|
log.append("advisor-group/end", data);
|
|
928
1441
|
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Open the cross-session ask card (waiting state). Reuses the registered
|
|
1444
|
+
* `advisor-group/start` event with `kind:'ask'` so no new event type needs
|
|
1445
|
+
* persistence registration; the client branches on `kind` for the ask layout.
|
|
1446
|
+
*/
|
|
1447
|
+
function appendAskStart(log, info) {
|
|
1448
|
+
const { turn, step } = latestTurnStep(log);
|
|
1449
|
+
log.append("advisor-group/start", {
|
|
1450
|
+
sessionId: info.askId,
|
|
1451
|
+
turn,
|
|
1452
|
+
step,
|
|
1453
|
+
kind: "ask",
|
|
1454
|
+
question: info.question,
|
|
1455
|
+
...info.context === void 0 ? {} : { context: info.context },
|
|
1456
|
+
advisors: [{
|
|
1457
|
+
id: "ask-sender",
|
|
1458
|
+
name: info.senderName
|
|
1459
|
+
}, {
|
|
1460
|
+
id: "ask-target",
|
|
1461
|
+
name: info.targetName
|
|
1462
|
+
}]
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Close the cross-session ask card: the target's answer as an advisor-role
|
|
1467
|
+
* bubble, then the end event whose `summary.conclusion` is the note line.
|
|
1468
|
+
* Message is appended BEFORE end so the assembled state has the answer in
|
|
1469
|
+
* place when the status flips to completed.
|
|
1470
|
+
*/
|
|
1471
|
+
function appendAskAnswer(log, info) {
|
|
1472
|
+
const { turn, step } = latestTurnStep(log);
|
|
1473
|
+
log.append("advisor-group/message", {
|
|
1474
|
+
sessionId: info.askId,
|
|
1475
|
+
turn,
|
|
1476
|
+
step,
|
|
1477
|
+
role: "advisor",
|
|
1478
|
+
advisorId: "ask-target",
|
|
1479
|
+
advisorName: info.targetName,
|
|
1480
|
+
content: info.answer.trim() ? info.answer : "(未收到回复,详见下方投递说明)"
|
|
1481
|
+
});
|
|
1482
|
+
log.append("advisor-group/end", {
|
|
1483
|
+
sessionId: info.askId,
|
|
1484
|
+
turn,
|
|
1485
|
+
step,
|
|
1486
|
+
summary: {
|
|
1487
|
+
question: info.question,
|
|
1488
|
+
advisors: [{
|
|
1489
|
+
advisorId: "ask-target",
|
|
1490
|
+
advisorName: info.targetName,
|
|
1491
|
+
corePoints: []
|
|
1492
|
+
}],
|
|
1493
|
+
conclusion: info.note
|
|
1494
|
+
}
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
929
1497
|
//#endregion
|
|
930
1498
|
//#region src/stream-channel.ts
|
|
931
1499
|
const BOOT_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -998,12 +1566,212 @@ function publish(sessionId, delta) {
|
|
|
998
1566
|
for (const res of set) writeFrame(res, frame);
|
|
999
1567
|
}
|
|
1000
1568
|
//#endregion
|
|
1569
|
+
//#region src/advisor-tools.ts
|
|
1570
|
+
/** Raw tool-call / DSML markup that some providers echo as plain text instead
|
|
1571
|
+
* of a structured tool call. Observed shapes (deepseek-v4-pro, anthropic
|
|
1572
|
+
* channel):
|
|
1573
|
+
* - plain `<tool_calls>…</tool_calls>` (line-start block)
|
|
1574
|
+
* - piped `<||tool_calls>\n<||DSML|…` without a closing tag (U+FF5C)
|
|
1575
|
+
* - MID-LINE occurrence: `证据。<tool_calls><invoke name="bash">…` — the
|
|
1576
|
+
* marker is glued to prose, so line-start anchoring alone is NOT enough. */
|
|
1577
|
+
const RAW_TOOL_CALL_BLOCK = /<(?:[||]{1,2})?(?:tool_calls|DSML)[\s\S]*?<\/(?:[||]{1,2})?(?:tool_calls|DSML)\s*>/g;
|
|
1578
|
+
/** Unclosed tail: cut from the first marker that is actually a STRUCTURED
|
|
1579
|
+
* block (followed by `<invoke`/`<parameter`/`<||DSML` within the next ~60
|
|
1580
|
+
* chars). An isolated prose/code mention like `` `<tool_calls>` `` is never
|
|
1581
|
+
* followed by those children, so it stays untouched. */
|
|
1582
|
+
const RAW_TOOL_CALL_TAIL = /<(?:[||]{1,2})?(?:tool_calls|DSML)[\s\S]{0,60}?(?:<invoke|<parameter|<[||]{1,2}DSML)[\s\S]*$/g;
|
|
1583
|
+
/**
|
|
1584
|
+
* Strip raw `<tool_calls>` / piped `<||tool_calls>` / `<||DSML|` markup from
|
|
1585
|
+
* an advisor's final content (line-start OR mid-line, closed or unclosed).
|
|
1586
|
+
* Returns the cleaned text; when the content was only leaked tool markup, a
|
|
1587
|
+
* short notice replaces it so the bubble is never empty/raw (the thinking
|
|
1588
|
+
* panel and tool rows still carry the real substance).
|
|
1589
|
+
*/
|
|
1590
|
+
function sanitizeAdvisorContent(content) {
|
|
1591
|
+
if (!content.trim()) return content;
|
|
1592
|
+
return content.replace(RAW_TOOL_CALL_BLOCK, "").replace(RAW_TOOL_CALL_TAIL, "").trim() || "(顾问未生成正文:输出仅为工具调用描述,请参考其思维链与工具调用记录。)";
|
|
1593
|
+
}
|
|
1594
|
+
/** Conservative read-only whitelist for the default advisor tool scope. */
|
|
1595
|
+
const READONLY_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
1596
|
+
"read",
|
|
1597
|
+
"grep",
|
|
1598
|
+
"glob",
|
|
1599
|
+
"web_search",
|
|
1600
|
+
"web_fetch",
|
|
1601
|
+
"scan_discover",
|
|
1602
|
+
"list_imported_sessions"
|
|
1603
|
+
]);
|
|
1604
|
+
const MAX_TOOL_ROUNDS = 4;
|
|
1605
|
+
const TOOL_EXEC_TIMEOUT_MS = 3e4;
|
|
1606
|
+
const TOOL_RESULT_MAX_CHARS = 8e3;
|
|
1607
|
+
/**
|
|
1608
|
+
* Resolve the advisor-visible tool schemas. `agent` (when provided) makes the
|
|
1609
|
+
* registry resolve the scoped view — exactly the tools that session's Agent
|
|
1610
|
+
* loop serves, honouring presets and restrict() filters.
|
|
1611
|
+
*/
|
|
1612
|
+
function resolveAdvisorToolSchemas(ctx, agent, mode) {
|
|
1613
|
+
if (mode === "off") return [];
|
|
1614
|
+
const schemas = ctx.tools?.schemas?.(agent) ?? [];
|
|
1615
|
+
let list = schemas;
|
|
1616
|
+
if (mode === "readonly") list = schemas.filter((schema) => typeof schema === "object" && schema !== null && READONLY_TOOL_NAMES.has(schema.name));
|
|
1617
|
+
return list.map((schema) => {
|
|
1618
|
+
const { name, description, parameters } = schema;
|
|
1619
|
+
return {
|
|
1620
|
+
name,
|
|
1621
|
+
...typeof description === "string" ? { description } : {},
|
|
1622
|
+
...parameters === void 0 ? {} : { parameters }
|
|
1623
|
+
};
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
/** Human-readable one-line summary of a tool result (for the thinking panel). */
|
|
1627
|
+
function summarizeToolResult(result) {
|
|
1628
|
+
let text = "";
|
|
1629
|
+
if (result.isError) text = `(错误)${result.error?.message ?? "unknown"}`;
|
|
1630
|
+
else {
|
|
1631
|
+
text = (result.content ?? []).map((block) => typeof block === "object" && block !== null && "text" in block && typeof block.text === "string" ? block.text : "").join("").trim();
|
|
1632
|
+
if (!text && result.value !== void 0) try {
|
|
1633
|
+
text = JSON.stringify(result.value);
|
|
1634
|
+
} catch {
|
|
1635
|
+
text = String(result.value);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
return text.length > TOOL_RESULT_MAX_CHARS ? `${text.slice(0, TOOL_RESULT_MAX_CHARS)}…(已截断)` : text;
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Execute one tool via the official pipeline (scoped dispatch when `agent` is
|
|
1642
|
+
* provided). Returns the model-facing text; never throws.
|
|
1643
|
+
*/
|
|
1644
|
+
async function executeAdvisorTool(ctx, agent, call, signal) {
|
|
1645
|
+
const tools = ctx.tools;
|
|
1646
|
+
if (!tools?.execute) return "(顾问工具执行不可用:当前环境未提供工具执行服务)";
|
|
1647
|
+
let args = {};
|
|
1648
|
+
try {
|
|
1649
|
+
args = call.argumentsJson ? JSON.parse(call.argumentsJson) : {};
|
|
1650
|
+
} catch {
|
|
1651
|
+
return "(工具参数不是合法 JSON)";
|
|
1652
|
+
}
|
|
1653
|
+
try {
|
|
1654
|
+
return summarizeToolResult(await tools.execute({
|
|
1655
|
+
callId: call.id,
|
|
1656
|
+
name: call.name,
|
|
1657
|
+
arguments: args,
|
|
1658
|
+
...agent === void 0 ? {} : { agent },
|
|
1659
|
+
signal: withTimeout(TOOL_EXEC_TIMEOUT_MS, signal)
|
|
1660
|
+
}));
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
return `(工具执行失败:${error instanceof Error ? error.message : String(error)})`;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Drive the tool-calling loop: stream with `tools` → execute every
|
|
1667
|
+
* model-requested call (each step surfaced via `onToolStep` as its own row) →
|
|
1668
|
+
* feed a compact "already executed" block back → repeat until the model
|
|
1669
|
+
* answers (or MAX_TOOL_ROUNDS, then one final no-tools round to force a text
|
|
1670
|
+
* answer).
|
|
1671
|
+
*/
|
|
1672
|
+
async function runAdvisorToolLoop(tools, streamOnce, executeTool, onToolStep) {
|
|
1673
|
+
let extraContext = "";
|
|
1674
|
+
let truncated;
|
|
1675
|
+
const executedMemo = /* @__PURE__ */ new Map();
|
|
1676
|
+
const executeDeduped = async (call) => {
|
|
1677
|
+
const key = `${call.name}:${call.argumentsJson}`;
|
|
1678
|
+
const cached = executedMemo.get(key);
|
|
1679
|
+
if (cached !== void 0) {
|
|
1680
|
+
console.warn(`[dsh-advisor-group] 同参工具调用去重(复用已执行结果):${call.name}`);
|
|
1681
|
+
return {
|
|
1682
|
+
outcome: cached,
|
|
1683
|
+
reused: true
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
const outcome = await executeTool(call);
|
|
1687
|
+
executedMemo.set(key, outcome);
|
|
1688
|
+
return {
|
|
1689
|
+
outcome,
|
|
1690
|
+
reused: false
|
|
1691
|
+
};
|
|
1692
|
+
};
|
|
1693
|
+
/** Result fed back to the model: capped so extraContext never balloons
|
|
1694
|
+
* (display rows keep their own 240-char preview). */
|
|
1695
|
+
const capOutcome = (outcome) => outcome.length > 800 ? `${outcome.slice(0, 800)}…` : outcome;
|
|
1696
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
1697
|
+
const result = await streamOnce(tools, extraContext);
|
|
1698
|
+
if (result.toolCalls.length === 0) return {
|
|
1699
|
+
content: result.content,
|
|
1700
|
+
truncated: result.truncated
|
|
1701
|
+
};
|
|
1702
|
+
truncated = result.truncated ?? truncated;
|
|
1703
|
+
const executed = [];
|
|
1704
|
+
for (const call of result.toolCalls) {
|
|
1705
|
+
const argsPreview = call.argumentsJson.length > 120 ? `${call.argumentsJson.slice(0, 120)}…` : call.argumentsJson;
|
|
1706
|
+
onToolStep({
|
|
1707
|
+
kind: "call",
|
|
1708
|
+
name: call.name,
|
|
1709
|
+
text: argsPreview || "无参数"
|
|
1710
|
+
});
|
|
1711
|
+
if (!READONLY_TOOL_NAMES.has(call.name)) console.warn(`[dsh-advisor-group] 顾问调用非只读工具(${call.name}):`, argsPreview);
|
|
1712
|
+
const { outcome, reused } = await executeDeduped(call);
|
|
1713
|
+
if (outcome.trim()) {
|
|
1714
|
+
const flat = outcome.replace(/\n+/g, " ").trim();
|
|
1715
|
+
const preview = flat.length > 240 ? `${flat.slice(0, 240)}…` : flat;
|
|
1716
|
+
onToolStep({
|
|
1717
|
+
kind: "result",
|
|
1718
|
+
name: call.name,
|
|
1719
|
+
text: `${reused ? "(复用已执行结果)" : ""}${preview}`
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
executed.push(`- ${call.name}${call.argumentsJson ? ` 参数:${call.argumentsJson.slice(0, 200)}` : ""}\n结果:${capOutcome(outcome)}`);
|
|
1723
|
+
}
|
|
1724
|
+
extraContext = `${extraContext}\n\n【顾问已执行工具】\n${executed.join("\n\n")}`;
|
|
1725
|
+
}
|
|
1726
|
+
const final = await streamOnce([], extraContext);
|
|
1727
|
+
if (final.toolCalls.length > 0) {
|
|
1728
|
+
const executed = [];
|
|
1729
|
+
for (const call of final.toolCalls) {
|
|
1730
|
+
const argsPreview = call.argumentsJson.length > 120 ? `${call.argumentsJson.slice(0, 120)}…` : call.argumentsJson;
|
|
1731
|
+
onToolStep({
|
|
1732
|
+
kind: "call",
|
|
1733
|
+
name: call.name,
|
|
1734
|
+
text: argsPreview || "无参数"
|
|
1735
|
+
});
|
|
1736
|
+
if (!READONLY_TOOL_NAMES.has(call.name)) console.warn(`[dsh-advisor-group] 顾问调用非只读工具(${call.name}):`, argsPreview);
|
|
1737
|
+
const { outcome, reused } = await executeDeduped(call);
|
|
1738
|
+
if (outcome.trim()) {
|
|
1739
|
+
const flat = outcome.replace(/\n+/g, " ").trim();
|
|
1740
|
+
const preview = flat.length > 240 ? `${flat.slice(0, 240)}…` : flat;
|
|
1741
|
+
onToolStep({
|
|
1742
|
+
kind: "result",
|
|
1743
|
+
name: call.name,
|
|
1744
|
+
text: `${reused ? "(复用已执行结果)" : ""}${preview}`
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
executed.push(`- ${call.name}${call.argumentsJson ? ` 参数:${call.argumentsJson.slice(0, 200)}` : ""}\n结果:${capOutcome(outcome)}`);
|
|
1748
|
+
}
|
|
1749
|
+
const final2 = await streamOnce([], `${extraContext}\n\n【顾问已执行工具】\n${executed.join("\n\n")}`);
|
|
1750
|
+
return {
|
|
1751
|
+
content: final2.content,
|
|
1752
|
+
truncated: final2.truncated ?? final.truncated ?? truncated
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
return {
|
|
1756
|
+
content: final.content,
|
|
1757
|
+
truncated: final.truncated ?? truncated
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
//#endregion
|
|
1001
1761
|
//#region src/service.ts
|
|
1002
1762
|
function isAbortError(error, signal) {
|
|
1003
1763
|
if (signal?.aborted) return true;
|
|
1004
1764
|
return error instanceof Error && error.name === "AbortError";
|
|
1005
1765
|
}
|
|
1006
|
-
const
|
|
1766
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1767
|
+
/** Real consult ids are server-minted uuids; anything else is rejected. */
|
|
1768
|
+
function isSafeConsultId(id) {
|
|
1769
|
+
return UUID_RE.test(id);
|
|
1770
|
+
}
|
|
1771
|
+
/** Agent DSH session ids look like `session-<uuid>` or a plain uuid. */
|
|
1772
|
+
function isSafeDshSessionId(id) {
|
|
1773
|
+
return UUID_RE.test(id) || UUID_RE.test(id.replace(/^session-/i, ""));
|
|
1774
|
+
}
|
|
1007
1775
|
function extractUserMessageText(event) {
|
|
1008
1776
|
if (event.type !== "user/message") return null;
|
|
1009
1777
|
const data = event.data;
|
|
@@ -1021,6 +1789,11 @@ var AdvisorGroupService = class {
|
|
|
1021
1789
|
/** Durable daily-guard counter fixture under the DSH home storage area. */
|
|
1022
1790
|
dailyGuardPath;
|
|
1023
1791
|
dailyWriteChain = Promise.resolve();
|
|
1792
|
+
/** Per-session stop controllers for the auto-deepen pipeline. */
|
|
1793
|
+
activeAborts = /* @__PURE__ */ new Map();
|
|
1794
|
+
/** Durable consultation snapshots (usable after a dsh restart). */
|
|
1795
|
+
sessionsDir;
|
|
1796
|
+
sessionWriteChain = Promise.resolve();
|
|
1024
1797
|
enabled;
|
|
1025
1798
|
config;
|
|
1026
1799
|
constructor(ctx, config) {
|
|
@@ -1029,7 +1802,9 @@ var AdvisorGroupService = class {
|
|
|
1029
1802
|
this.enabled = config.enabled;
|
|
1030
1803
|
const dshHome = process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
1031
1804
|
this.dailyGuardPath = join(dshHome, "storages", "advisor-group", "daily-guard.json");
|
|
1805
|
+
this.sessionsDir = join(dshHome, "storages", "advisor-group", "sessions");
|
|
1032
1806
|
this.loadDailyGuard();
|
|
1807
|
+
this.loadStoredSessions();
|
|
1033
1808
|
ctx.on("session/event", (session, event) => {
|
|
1034
1809
|
const text = extractUserMessageText(event);
|
|
1035
1810
|
if (!text) return;
|
|
@@ -1065,19 +1840,241 @@ var AdvisorGroupService = class {
|
|
|
1065
1840
|
console.warn("[dsh-advisor-group] 每日护栏持久化失败:", error instanceof Error ? error.message : String(error));
|
|
1066
1841
|
});
|
|
1067
1842
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1843
|
+
/**
|
|
1844
|
+
* Restore interrupted consultations from durable snapshots so the card's
|
|
1845
|
+
* 「▶ 继续聊天」 button still works after a dsh restart. Completed sessions
|
|
1846
|
+
* are not restored (they cannot resume) and their snapshot is cleaned up.
|
|
1847
|
+
*/
|
|
1848
|
+
loadStoredSessions() {
|
|
1849
|
+
let files;
|
|
1850
|
+
try {
|
|
1851
|
+
files = readdirSync(this.sessionsDir).filter((name) => name.endsWith(".json"));
|
|
1852
|
+
} catch {
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
for (const file of files) try {
|
|
1856
|
+
const raw = JSON.parse(readFileSync(join(this.sessionsDir, file), "utf8"));
|
|
1857
|
+
if (raw.version !== 1 || typeof raw.id !== "string" || typeof raw.question !== "string") continue;
|
|
1858
|
+
if (!isSafeConsultId(raw.id)) continue;
|
|
1859
|
+
if (raw.dshSessionId !== void 0 && !isSafeDshSessionId(raw.dshSessionId)) continue;
|
|
1860
|
+
if (raw.status === "completed") {
|
|
1861
|
+
unlinkSync(join(this.sessionsDir, file));
|
|
1862
|
+
continue;
|
|
1863
|
+
}
|
|
1864
|
+
const advisors = this.resolveAdvisors(raw.advisorIds);
|
|
1865
|
+
if (advisors.length === 0) continue;
|
|
1866
|
+
const session = {
|
|
1867
|
+
id: raw.id,
|
|
1868
|
+
status: "cancelled",
|
|
1869
|
+
question: raw.question,
|
|
1870
|
+
context: raw.context,
|
|
1871
|
+
advisors,
|
|
1872
|
+
maxRounds: raw.maxRounds,
|
|
1873
|
+
messages: raw.messages.map((message) => ({
|
|
1874
|
+
role: message.role,
|
|
1875
|
+
advisorId: message.advisorId,
|
|
1876
|
+
advisorName: message.advisorName,
|
|
1877
|
+
content: message.content,
|
|
1878
|
+
thinking: message.thinking,
|
|
1879
|
+
thinkingSegments: message.thinkingSegments,
|
|
1880
|
+
actionDescriptions: message.actionDescriptions,
|
|
1881
|
+
round: message.round,
|
|
1882
|
+
truncated: message.truncated,
|
|
1883
|
+
toolSteps: message.toolSteps,
|
|
1884
|
+
ts: message.ts
|
|
1885
|
+
})),
|
|
1886
|
+
cwd: raw.cwd,
|
|
1887
|
+
dshSessionId: raw.dshSessionId,
|
|
1888
|
+
driverSource: raw.driverSource,
|
|
1889
|
+
stopReason: raw.stopReason ?? "abort-error",
|
|
1890
|
+
createdAt: raw.createdAt,
|
|
1891
|
+
updatedAt: raw.updatedAt
|
|
1892
|
+
};
|
|
1893
|
+
this.sessions.set(session.id, session);
|
|
1894
|
+
} catch (error) {
|
|
1895
|
+
console.warn("[dsh-advisor-group] 会话快照读取失败,已跳过:", file, error instanceof Error ? error.message : String(error));
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
/** Serialization-safe snapshot: advisor identity only, NEVER credentials. */
|
|
1899
|
+
snapshotPayload(session) {
|
|
1072
1900
|
return {
|
|
1073
|
-
|
|
1074
|
-
|
|
1901
|
+
version: 1,
|
|
1902
|
+
id: session.id,
|
|
1903
|
+
status: session.status,
|
|
1904
|
+
question: session.question,
|
|
1905
|
+
...session.context === void 0 ? {} : { context: session.context },
|
|
1906
|
+
...session.cwd === void 0 ? {} : { cwd: session.cwd },
|
|
1907
|
+
...session.dshSessionId === void 0 ? {} : { dshSessionId: session.dshSessionId },
|
|
1908
|
+
...session.driverSource === void 0 ? {} : { driverSource: session.driverSource },
|
|
1909
|
+
...session.stopReason === void 0 ? {} : { stopReason: session.stopReason },
|
|
1910
|
+
advisorIds: session.advisors.map((advisor) => advisor.id),
|
|
1911
|
+
maxRounds: session.maxRounds,
|
|
1912
|
+
createdAt: session.createdAt,
|
|
1913
|
+
updatedAt: session.updatedAt,
|
|
1914
|
+
messages: session.messages.map((message) => ({
|
|
1915
|
+
role: message.role,
|
|
1916
|
+
...message.advisorId === void 0 ? {} : { advisorId: message.advisorId },
|
|
1917
|
+
...message.advisorName === void 0 ? {} : { advisorName: message.advisorName },
|
|
1918
|
+
content: message.content,
|
|
1919
|
+
...message.thinking === void 0 ? {} : { thinking: message.thinking },
|
|
1920
|
+
...message.thinkingSegments === void 0 ? {} : { thinkingSegments: message.thinkingSegments },
|
|
1921
|
+
...message.actionDescriptions === void 0 ? {} : { actionDescriptions: message.actionDescriptions },
|
|
1922
|
+
...message.round === void 0 ? {} : { round: message.round },
|
|
1923
|
+
...message.truncated === void 0 ? {} : { truncated: message.truncated },
|
|
1924
|
+
...message.toolSteps === void 0 ? {} : { toolSteps: message.toolSteps },
|
|
1925
|
+
ts: message.ts
|
|
1926
|
+
}))
|
|
1075
1927
|
};
|
|
1076
1928
|
}
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1929
|
+
/** Serialized, atomic (tmp + rename) consultation snapshot persistence. */
|
|
1930
|
+
persistSession(session) {
|
|
1931
|
+
if (!isSafeConsultId(session.id)) {
|
|
1932
|
+
console.warn("[dsh-advisor-group] 拒绝持久化非法会话 id:", session.id);
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
const payload = JSON.stringify(this.snapshotPayload(session));
|
|
1936
|
+
const target = join(this.sessionsDir, `${session.id}.json`);
|
|
1937
|
+
this.sessionWriteChain = this.sessionWriteChain.then(async () => {
|
|
1938
|
+
await promises.mkdir(this.sessionsDir, { recursive: true });
|
|
1939
|
+
const tmp = `${target}.tmp`;
|
|
1940
|
+
await promises.writeFile(tmp, payload, "utf8");
|
|
1941
|
+
await promises.rename(tmp, target);
|
|
1942
|
+
}).catch((error) => {
|
|
1943
|
+
console.warn("[dsh-advisor-group] 咨询快照持久化失败:", session.id, error instanceof Error ? error.message : String(error));
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
/**
|
|
1947
|
+
* Card-facing advisor failure text: credentials redacted, collapsed to one
|
|
1948
|
+
* line, length-capped. The full raw error is logged server-side instead.
|
|
1949
|
+
*/
|
|
1950
|
+
summarizeAdvisorError(raw) {
|
|
1951
|
+
const singleLine = raw.replace(/\b(api[_-]?key["']?\s*[:=]\s*["']?)[^\s"',}&]+/gi, "$1[已隐藏]").replace(/([?&]key=)[^\s&"']+/gi, "$1[已隐藏]").replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [已隐藏]").replace(/\bsk-[A-Za-z0-9_-]{8,}/g, "[已隐藏]").replace(/\s+/g, " ").trim();
|
|
1952
|
+
if (singleLine.length <= 300) return singleLine;
|
|
1953
|
+
return `${singleLine.slice(0, 300)}…(完整错误已记录到服务端日志)`;
|
|
1954
|
+
}
|
|
1955
|
+
/** Wait for queued durable writes before a host or test tears down storage. */
|
|
1956
|
+
async flushPersistence() {
|
|
1957
|
+
await Promise.all([this.dailyWriteChain, this.sessionWriteChain]);
|
|
1958
|
+
}
|
|
1959
|
+
/** Structural view of the DSH in-memory session store (may be absent). */
|
|
1960
|
+
get sessionStore() {
|
|
1961
|
+
let store;
|
|
1962
|
+
try {
|
|
1963
|
+
store = this.ctx.sessions;
|
|
1964
|
+
} catch {
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
if (!store || typeof store !== "object") return void 0;
|
|
1968
|
+
const typed = store;
|
|
1969
|
+
if (typeof typed.get !== "function" || typeof typed.prepare !== "function" || typeof typed.enter !== "function" || typeof typed.flush !== "function") return;
|
|
1970
|
+
return store;
|
|
1971
|
+
}
|
|
1972
|
+
/** Best-effort durability barrier for the advisor-group log events. */
|
|
1973
|
+
async flushLog(sessionLog) {
|
|
1974
|
+
if (!sessionLog) return;
|
|
1975
|
+
const store = this.sessionStore;
|
|
1976
|
+
if (!store) return;
|
|
1977
|
+
try {
|
|
1978
|
+
await store.flush(sessionLog);
|
|
1979
|
+
} catch {}
|
|
1980
|
+
}
|
|
1981
|
+
/** Build a contiguous seed log from the snapshot (cross-restart resume). */
|
|
1982
|
+
buildSeedEvents(session) {
|
|
1983
|
+
const events = [];
|
|
1984
|
+
let seq = 0;
|
|
1985
|
+
events.push({
|
|
1986
|
+
type: "advisor-group/start",
|
|
1987
|
+
seq: seq++,
|
|
1988
|
+
time: session.createdAt,
|
|
1989
|
+
data: {
|
|
1990
|
+
sessionId: session.id,
|
|
1991
|
+
turn: 0,
|
|
1992
|
+
step: 0,
|
|
1993
|
+
question: session.question,
|
|
1994
|
+
...session.context === void 0 ? {} : { context: session.context },
|
|
1995
|
+
advisors: session.advisors.map(({ id, name, avatar }) => avatar === void 0 ? {
|
|
1996
|
+
id,
|
|
1997
|
+
name
|
|
1998
|
+
} : {
|
|
1999
|
+
id,
|
|
2000
|
+
name,
|
|
2001
|
+
avatar
|
|
2002
|
+
})
|
|
2003
|
+
}
|
|
2004
|
+
});
|
|
2005
|
+
for (const message of session.messages) {
|
|
2006
|
+
if (message.role === "system") continue;
|
|
2007
|
+
events.push({
|
|
2008
|
+
type: "advisor-group/message",
|
|
2009
|
+
seq: seq++,
|
|
2010
|
+
time: message.ts || Date.now(),
|
|
2011
|
+
data: {
|
|
2012
|
+
sessionId: session.id,
|
|
2013
|
+
turn: 0,
|
|
2014
|
+
step: 0,
|
|
2015
|
+
role: message.role,
|
|
2016
|
+
content: message.content,
|
|
2017
|
+
...message.advisorId === void 0 ? {} : { advisorId: message.advisorId },
|
|
2018
|
+
...message.advisorName === void 0 ? {} : { advisorName: message.advisorName },
|
|
2019
|
+
...message.thinking === void 0 ? {} : { thinking: message.thinking },
|
|
2020
|
+
...message.round === void 0 ? {} : { round: message.round },
|
|
2021
|
+
...message.truncated === void 0 ? {} : { truncated: message.truncated }
|
|
2022
|
+
}
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
return events;
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Obtain the DSH Session handle for durable log events. Prefers the live
|
|
2029
|
+
* store entry (same process). After a restart the store is empty, so the
|
|
2030
|
+
* session is rebuilt with `prepare` + `enter` (append hooks publish events
|
|
2031
|
+
* and the persistence layer picks them up; the session is NOT announced, so
|
|
2032
|
+
* no agent lifecycle reactivation is triggered). Falls back to a detached
|
|
2033
|
+
* `Session.create` (live SSE + snapshot only, no durable log) when neither
|
|
2034
|
+
* works.
|
|
2035
|
+
*/
|
|
2036
|
+
resolveSessionHandle(session) {
|
|
2037
|
+
const store = this.sessionStore;
|
|
2038
|
+
const resumeId = session.dshSessionId !== void 0 && isSafeDshSessionId(session.dshSessionId) ? session.dshSessionId : isSafeConsultId(session.id) ? session.id : "";
|
|
2039
|
+
if (!resumeId) return void 0;
|
|
2040
|
+
const live = store?.get(resumeId);
|
|
2041
|
+
if (live) return live;
|
|
2042
|
+
if (!store) return this.buildDetachedSession(session, resumeId);
|
|
2043
|
+
try {
|
|
2044
|
+
const meta = { cwd: session.cwd !== void 0 && isAbsolute(session.cwd) ? session.cwd : process.cwd() };
|
|
2045
|
+
const prepared = store.prepare(resumeId, {
|
|
2046
|
+
seed: this.buildSeedEvents(session),
|
|
2047
|
+
meta
|
|
2048
|
+
});
|
|
2049
|
+
store.enter(prepared);
|
|
2050
|
+
return prepared;
|
|
2051
|
+
} catch (error) {
|
|
2052
|
+
console.warn("[dsh-advisor-group] 重建会话句柄失败(降级为 detached):", session.id, error instanceof Error ? error.message : String(error));
|
|
2053
|
+
const again = store.get(resumeId);
|
|
2054
|
+
if (again) return again;
|
|
2055
|
+
return this.buildDetachedSession(session, resumeId);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
buildDetachedSession(session, sessionId = session.id) {
|
|
2059
|
+
try {
|
|
2060
|
+
return Session.create(sessionId, this.buildSeedEvents(session), void 0, 0);
|
|
2061
|
+
} catch {
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
getRepeatPressure(sessionId) {
|
|
2066
|
+
if (!sessionId) return void 0;
|
|
2067
|
+
const tracked = this.repeatTracker.get(sessionId);
|
|
2068
|
+
if (!tracked) return void 0;
|
|
2069
|
+
return {
|
|
2070
|
+
count: tracked.count,
|
|
2071
|
+
text: tracked.text
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
getConfig() {
|
|
2075
|
+
return this.config;
|
|
2076
|
+
}
|
|
2077
|
+
setConfig(config) {
|
|
1081
2078
|
this.config = config;
|
|
1082
2079
|
this.enabled = config.enabled;
|
|
1083
2080
|
}
|
|
@@ -1098,26 +2095,35 @@ var AdvisorGroupService = class {
|
|
|
1098
2095
|
this.setEnabled(enabled);
|
|
1099
2096
|
if (this.persistEnabledCallback) try {
|
|
1100
2097
|
await this.persistEnabledCallback(enabled);
|
|
1101
|
-
} catch {
|
|
2098
|
+
} catch (error) {
|
|
2099
|
+
console.warn("[dsh-advisor-group] 启用开关保存失败(内存态未回滚):", error instanceof Error ? error.message : String(error));
|
|
2100
|
+
}
|
|
1102
2101
|
return this.enabled;
|
|
1103
2102
|
}
|
|
1104
2103
|
/**
|
|
1105
2104
|
* Current daily-guard facts for display (does NOT increment — read-only).
|
|
1106
2105
|
* Rolls the in-memory counter to a new UTC day view without persisting.
|
|
2106
|
+
* `enabled` reflects the configurable cap; `remaining` is -1 when the cap
|
|
2107
|
+
* is disabled (the counter still accumulates for visibility).
|
|
1107
2108
|
*/
|
|
1108
2109
|
getDailyGuard() {
|
|
1109
2110
|
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1110
2111
|
const used = this.dailyConsultationDate === today ? this.dailyConsultationCount : 0;
|
|
2112
|
+
const enabled = this.config.quota.enabled;
|
|
2113
|
+
const limit = this.config.quota.maxPerDay;
|
|
1111
2114
|
return {
|
|
1112
2115
|
used,
|
|
1113
|
-
|
|
1114
|
-
|
|
2116
|
+
enabled,
|
|
2117
|
+
limit,
|
|
2118
|
+
remaining: enabled ? Math.max(0, limit - used) : -1
|
|
1115
2119
|
};
|
|
1116
2120
|
}
|
|
1117
2121
|
/**
|
|
1118
2122
|
* Minimal cost guard: atomically check and increment the per-day new
|
|
1119
2123
|
* consultation counter. Node is single-threaded, so doing both inside one
|
|
1120
2124
|
* synchronous block closes the TOCTOU window a two-step check would leave.
|
|
2125
|
+
* The cap is configurable (`quota.enabled` / `quota.maxPerDay`); when the
|
|
2126
|
+
* cap is disabled the counter still increments (used for display only).
|
|
1121
2127
|
* The counter is persisted (UTC day key) so a harness restart does not
|
|
1122
2128
|
* reset the quota; persistence is fire-and-forget after the sync block.
|
|
1123
2129
|
*/
|
|
@@ -1127,9 +2133,9 @@ var AdvisorGroupService = class {
|
|
|
1127
2133
|
this.dailyConsultationDate = today;
|
|
1128
2134
|
this.dailyConsultationCount = 0;
|
|
1129
2135
|
}
|
|
1130
|
-
if (this.dailyConsultationCount >=
|
|
2136
|
+
if (this.config.quota.enabled && this.dailyConsultationCount >= this.config.quota.maxPerDay) return {
|
|
1131
2137
|
ok: false,
|
|
1132
|
-
reason: "
|
|
2138
|
+
reason: "今日顾问咨询次数已达上限,请明天再试,或在设置中调整每日咨询上限。"
|
|
1133
2139
|
};
|
|
1134
2140
|
this.dailyConsultationCount += 1;
|
|
1135
2141
|
this.persistDailyGuard();
|
|
@@ -1138,9 +2144,10 @@ var AdvisorGroupService = class {
|
|
|
1138
2144
|
getSession(id) {
|
|
1139
2145
|
return this.sessions.get(id);
|
|
1140
2146
|
}
|
|
1141
|
-
createSession(question, context, advisorIds = []) {
|
|
2147
|
+
createSession(question, context, advisorIds = [], cwd, dshSessionId) {
|
|
1142
2148
|
const maxAdvisors = this.config.discussion.maxAdvisorsPerCall;
|
|
1143
2149
|
const advisors = this.resolveAdvisors(advisorIds).slice(0, maxAdvisors);
|
|
2150
|
+
const now = Date.now();
|
|
1144
2151
|
const session = {
|
|
1145
2152
|
id: randomUUID(),
|
|
1146
2153
|
status: "active",
|
|
@@ -1151,20 +2158,23 @@ var AdvisorGroupService = class {
|
|
|
1151
2158
|
messages: [{
|
|
1152
2159
|
role: "system",
|
|
1153
2160
|
content: `顾问群已建立,共 ${advisors.length} 位顾问。`,
|
|
1154
|
-
ts:
|
|
2161
|
+
ts: now
|
|
1155
2162
|
}, {
|
|
1156
2163
|
role: "main",
|
|
1157
2164
|
content: context ? `${context}\n\n${question}` : question,
|
|
1158
|
-
ts:
|
|
2165
|
+
ts: now
|
|
1159
2166
|
}],
|
|
1160
|
-
|
|
1161
|
-
|
|
2167
|
+
cwd,
|
|
2168
|
+
dshSessionId,
|
|
2169
|
+
createdAt: now,
|
|
2170
|
+
updatedAt: now
|
|
1162
2171
|
};
|
|
1163
2172
|
if (this.sessions.size >= 100) {
|
|
1164
2173
|
const oldest = this.sessions.keys().next().value;
|
|
1165
2174
|
if (oldest !== void 0) this.sessions.delete(oldest);
|
|
1166
2175
|
}
|
|
1167
2176
|
this.sessions.set(session.id, session);
|
|
2177
|
+
this.persistSession(session);
|
|
1168
2178
|
this.ctx.emit("advisor-group/session-start", session);
|
|
1169
2179
|
return session;
|
|
1170
2180
|
}
|
|
@@ -1179,22 +2189,60 @@ var AdvisorGroupService = class {
|
|
|
1179
2189
|
session.messages.push(message);
|
|
1180
2190
|
session.updatedAt = Date.now();
|
|
1181
2191
|
if (sessionLog) appendAdvisorMessage(sessionLog, session.id, message);
|
|
2192
|
+
this.persistSession(session);
|
|
1182
2193
|
this.ctx.emit("advisor-group/message", {
|
|
1183
2194
|
sessionId: session.id,
|
|
1184
2195
|
message
|
|
1185
2196
|
});
|
|
1186
2197
|
return session;
|
|
1187
2198
|
}
|
|
1188
|
-
|
|
2199
|
+
/**
|
|
2200
|
+
* The next round that still needs work, derived from the durable messages:
|
|
2201
|
+
* when the last round is incomplete (an advisor answered, a later one was
|
|
2202
|
+
* cut by a stop), that round resumes with ONLY the missing advisors; when
|
|
2203
|
+
* it is complete, the next round is fresh. `undefined` means finished.
|
|
2204
|
+
*/
|
|
2205
|
+
nextPendingRound(session) {
|
|
2206
|
+
const advisorMessages = session.messages.filter((message) => message.role === "advisor");
|
|
2207
|
+
const maxRound = advisorMessages.reduce((max, message) => Math.max(max, message.round ?? 1), 0);
|
|
1189
2208
|
const advisorCount = Math.max(1, session.advisors.length);
|
|
1190
|
-
|
|
2209
|
+
if (maxRound === 0) return {
|
|
2210
|
+
round: 1,
|
|
2211
|
+
answeredIds: []
|
|
2212
|
+
};
|
|
2213
|
+
if (maxRound > session.maxRounds) return void 0;
|
|
2214
|
+
const answeredInMax = advisorMessages.filter((message) => (message.round ?? 1) === maxRound).map((message) => message.advisorId ?? "").filter((id) => id !== "");
|
|
2215
|
+
const unique = [...new Set(answeredInMax)];
|
|
2216
|
+
if (unique.length < advisorCount) return {
|
|
2217
|
+
round: maxRound,
|
|
2218
|
+
answeredIds: unique
|
|
2219
|
+
};
|
|
2220
|
+
if (maxRound + 1 > session.maxRounds) return void 0;
|
|
2221
|
+
return {
|
|
2222
|
+
round: maxRound + 1,
|
|
2223
|
+
answeredIds: []
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
/** Number of main-model messages: the opening question + all deep-questions. */
|
|
2227
|
+
mainMessageCount(session) {
|
|
2228
|
+
return session.messages.filter((message) => message.role === "main").length;
|
|
2229
|
+
}
|
|
2230
|
+
/** Run exactly one round: every advisor that has NOT answered this round. */
|
|
2231
|
+
async runRoundFrom(session, round, answeredIds, signal, sessionLog, agent) {
|
|
1191
2232
|
for (let index = 0; index < session.advisors.length; index++) {
|
|
1192
2233
|
const advisor = session.advisors[index];
|
|
2234
|
+
if (answeredIds.includes(advisor.id)) continue;
|
|
1193
2235
|
const transcript = this.buildTranscript(session);
|
|
1194
|
-
await this.callAdvisor(session, advisor, transcript, round, index + 1, signal, sessionLog);
|
|
2236
|
+
await this.callAdvisor(session, advisor, transcript, round, index + 1, signal, sessionLog, agent);
|
|
1195
2237
|
}
|
|
1196
2238
|
session.status = "active";
|
|
1197
2239
|
session.updatedAt = Date.now();
|
|
2240
|
+
this.persistSession(session);
|
|
2241
|
+
}
|
|
2242
|
+
async runOneRound(session, signal, sessionLog, agent) {
|
|
2243
|
+
const pending = this.nextPendingRound(session);
|
|
2244
|
+
if (!pending) return;
|
|
2245
|
+
await this.runRoundFrom(session, pending.round, pending.answeredIds, signal, sessionLog, agent);
|
|
1198
2246
|
}
|
|
1199
2247
|
/**
|
|
1200
2248
|
* Auto-deepen pipeline (2026-09-05): one ask_advisors call runs the whole
|
|
@@ -1202,21 +2250,159 @@ var AdvisorGroupService = class {
|
|
|
1202
2250
|
* [driver deep-question (after the first)] → advisor A → advisor B (sees A)
|
|
1203
2251
|
* → advisor C (sees A+B) → …
|
|
1204
2252
|
* and closes with a driver-generated conclusion. The pipeline is synchronous
|
|
1205
|
-
* (SSE keeps the card streaming); `exec.signal` aborts the whole run
|
|
2253
|
+
* (SSE keeps the card streaming); `exec.signal` aborts the whole run and the
|
|
2254
|
+
* user may stop it explicitly via `stopConsultation(sessionId)` (POST
|
|
2255
|
+
* /advisor-group/stop) — a stop degrades to a graceful partial summary.
|
|
2256
|
+
*
|
|
2257
|
+
* Resumption: after a stop (or a dsh restart that restored the snapshot)
|
|
2258
|
+
* `resumeConsultation()` re-enters the SAME pipeline; `nextPendingRound()`
|
|
2259
|
+
* detects the interruption point — a partially-answered round is completed
|
|
2260
|
+
* with only the missing advisors (the deep-question is NOT regenerated for
|
|
2261
|
+
* that round), then remaining rounds run as usual.
|
|
1206
2262
|
*/
|
|
1207
|
-
async runAutoPipeline(session, signal, sessionLog) {
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
2263
|
+
async runAutoPipeline(session, signal, sessionLog, agent) {
|
|
2264
|
+
const stop = new AbortController();
|
|
2265
|
+
this.activeAborts.set(session.id, stop);
|
|
2266
|
+
const combined = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal;
|
|
2267
|
+
if (!session.driverSource) {
|
|
2268
|
+
const source = resolveDriverSource(sessionLog, this.config.discussion.driverModel);
|
|
2269
|
+
if (source) {
|
|
2270
|
+
session.driverSource = source;
|
|
2271
|
+
this.persistSession(session);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
try {
|
|
2275
|
+
while (true) {
|
|
2276
|
+
const pending = this.nextPendingRound(session);
|
|
2277
|
+
if (!pending) break;
|
|
2278
|
+
const freshRound = pending.answeredIds.length === 0;
|
|
2279
|
+
const questionAlreadyAsked = this.mainMessageCount(session) >= pending.round;
|
|
2280
|
+
if (pending.round > 1 && freshRound && this.config.discussion.autoDeepen && !questionAlreadyAsked) {
|
|
2281
|
+
const question = await generateDeepenQuestion(this.ctx, session, session.driverSource ?? resolveDriverSource(sessionLog, this.config.discussion.driverModel), combined, this.config.discussion.driverTimeoutMs);
|
|
2282
|
+
this.appendMainMessage(session, question, sessionLog);
|
|
2283
|
+
}
|
|
2284
|
+
await this.runRoundFrom(session, pending.round, pending.answeredIds, combined, sessionLog, agent);
|
|
2285
|
+
}
|
|
2286
|
+
session.status = "completed";
|
|
2287
|
+
session.stopReason = void 0;
|
|
2288
|
+
const summary = await this.generateFinalSummary(session, combined, sessionLog, false);
|
|
2289
|
+
this.persistSession(session);
|
|
2290
|
+
await this.flushLog(sessionLog);
|
|
2291
|
+
return summary;
|
|
2292
|
+
} catch (error) {
|
|
2293
|
+
if (isAbortError(error, combined) || stop.signal.aborted) {
|
|
2294
|
+
if (stop.signal.aborted) session.stopReason = "user-stop";
|
|
2295
|
+
else if (signal?.aborted) session.stopReason = "exec-cancel";
|
|
2296
|
+
else session.stopReason = "abort-error";
|
|
2297
|
+
session.status = "cancelled";
|
|
2298
|
+
this.persistSession(session);
|
|
2299
|
+
const summary = await this.generateFinalSummary(session, void 0, sessionLog, true);
|
|
2300
|
+
await this.flushLog(sessionLog);
|
|
2301
|
+
return summary;
|
|
1212
2302
|
}
|
|
1213
|
-
|
|
2303
|
+
throw error;
|
|
2304
|
+
} finally {
|
|
2305
|
+
this.activeAborts.delete(session.id);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
/**
|
|
2309
|
+
* User-initiated stop: aborts the in-flight consultation pipeline. Returns
|
|
2310
|
+
* whether a running consultation was found and aborted.
|
|
2311
|
+
*/
|
|
2312
|
+
stopConsultation(sessionId) {
|
|
2313
|
+
const stop = this.activeAborts.get(sessionId);
|
|
2314
|
+
if (!stop) return false;
|
|
2315
|
+
stop.abort();
|
|
2316
|
+
return true;
|
|
2317
|
+
}
|
|
2318
|
+
/** True while a pipeline run (auto-deepen or a follow-up round) is in flight. */
|
|
2319
|
+
isSessionRunning(sessionId) {
|
|
2320
|
+
return this.activeAborts.has(sessionId);
|
|
2321
|
+
}
|
|
2322
|
+
/**
|
|
2323
|
+
* Resume a stopped/interrupted consultation from its interruption point.
|
|
2324
|
+
* Works for a user stop (live session) and after a dsh restart (snapshot
|
|
2325
|
+
* restored; the DSH session handle is rebuilt via `prepare` + `enter`).
|
|
2326
|
+
* Returns immediately; the pipeline runs in the background and streams over
|
|
2327
|
+
* the existing SSE channel, then closes with a fresh `advisor-group/end`.
|
|
2328
|
+
*/
|
|
2329
|
+
resumeConsultation(sessionId) {
|
|
2330
|
+
const session = this.sessions.get(sessionId);
|
|
2331
|
+
if (!session) return {
|
|
2332
|
+
ok: false,
|
|
2333
|
+
reason: "咨询会话不存在或已过期,请重新发起咨询。"
|
|
2334
|
+
};
|
|
2335
|
+
if (this.activeAborts.has(sessionId)) return {
|
|
2336
|
+
ok: false,
|
|
2337
|
+
reason: "该咨询正在运行中,无需继续。"
|
|
2338
|
+
};
|
|
2339
|
+
if (session.status === "completed" || this.nextPendingRound(session) === void 0) return {
|
|
2340
|
+
ok: false,
|
|
2341
|
+
reason: "该咨询已完成,无需继续。"
|
|
2342
|
+
};
|
|
2343
|
+
if (session.advisors.length === 0) return {
|
|
2344
|
+
ok: false,
|
|
2345
|
+
reason: "恢复失败:当前配置无法匹配该咨询的原顾问(顾问配置可能已被修改)。"
|
|
2346
|
+
};
|
|
2347
|
+
if (!this.acquireResumeLock(session.id)) return {
|
|
2348
|
+
ok: false,
|
|
2349
|
+
reason: "该咨询正在另一实例中恢复,请稍后再试(若确认无其他实例运行,10 分钟后会自动接管该锁)。"
|
|
2350
|
+
};
|
|
2351
|
+
const dshSession = this.resolveSessionHandle(session);
|
|
2352
|
+
const agentRegistry = this.ctx.agents;
|
|
2353
|
+
const resumeAgent = session.dshSessionId !== void 0 && agentRegistry?.get ? agentRegistry.get(session.dshSessionId) : void 0;
|
|
2354
|
+
session.status = "active";
|
|
2355
|
+
session.stopReason = void 0;
|
|
2356
|
+
session.updatedAt = Date.now();
|
|
2357
|
+
this.persistSession(session);
|
|
2358
|
+
if (dshSession) appendAdvisorResume(dshSession, session.id);
|
|
2359
|
+
this.ctx.emit("advisor-group/resume", { sessionId: session.id });
|
|
2360
|
+
this.runAutoPipeline(session, void 0, dshSession, resumeAgent).catch((error) => {
|
|
2361
|
+
console.error("[dsh-advisor-group] 续跑流水线失败:", session.id, error instanceof Error ? error.message : String(error));
|
|
2362
|
+
}).finally(() => this.releaseResumeLock(session.id));
|
|
2363
|
+
return { ok: true };
|
|
2364
|
+
}
|
|
2365
|
+
/** Atomic cross-instance lock (mkdir-based, with stale takeover). */
|
|
2366
|
+
acquireResumeLock(sessionId) {
|
|
2367
|
+
const lockDir = join(this.sessionsDir, `${sessionId}.lock`);
|
|
2368
|
+
try {
|
|
2369
|
+
mkdirSync(this.sessionsDir, { recursive: true });
|
|
2370
|
+
mkdirSync(lockDir);
|
|
2371
|
+
try {
|
|
2372
|
+
writeFileSync(join(lockDir, "owner"), String(process.pid), "utf8");
|
|
2373
|
+
} catch {}
|
|
2374
|
+
return true;
|
|
2375
|
+
} catch {
|
|
2376
|
+
try {
|
|
2377
|
+
const stat = statSync(lockDir);
|
|
2378
|
+
if (Date.now() - stat.mtimeMs > 6e5) {
|
|
2379
|
+
rmSync(lockDir, {
|
|
2380
|
+
recursive: true,
|
|
2381
|
+
force: true
|
|
2382
|
+
});
|
|
2383
|
+
mkdirSync(lockDir);
|
|
2384
|
+
return true;
|
|
2385
|
+
}
|
|
2386
|
+
} catch {}
|
|
2387
|
+
return false;
|
|
1214
2388
|
}
|
|
1215
|
-
|
|
2389
|
+
}
|
|
2390
|
+
releaseResumeLock(sessionId) {
|
|
2391
|
+
try {
|
|
2392
|
+
rmSync(join(this.sessionsDir, `${sessionId}.lock`), {
|
|
2393
|
+
recursive: true,
|
|
2394
|
+
force: true
|
|
2395
|
+
});
|
|
2396
|
+
} catch {}
|
|
2397
|
+
}
|
|
2398
|
+
async generateFinalSummary(session, signal, sessionLog, stopped) {
|
|
1216
2399
|
session.updatedAt = Date.now();
|
|
1217
|
-
const summary = this.buildSummary(session,
|
|
1218
|
-
const conclusion = await generateConclusion(this.ctx, session, resolveDriverSource(sessionLog, this.config.discussion.driverModel), signal);
|
|
1219
|
-
const finalSummary = {
|
|
2400
|
+
const summary = this.buildSummary(session, stopped);
|
|
2401
|
+
const conclusion = stopped ? "" : await generateConclusion(this.ctx, session, session.driverSource ?? resolveDriverSource(sessionLog, this.config.discussion.driverModel), signal, this.config.discussion.driverTimeoutMs);
|
|
2402
|
+
const finalSummary = stopped ? {
|
|
2403
|
+
...summary,
|
|
2404
|
+
stopped: true
|
|
2405
|
+
} : {
|
|
1220
2406
|
...summary,
|
|
1221
2407
|
conclusion
|
|
1222
2408
|
};
|
|
@@ -1235,6 +2421,7 @@ var AdvisorGroupService = class {
|
|
|
1235
2421
|
};
|
|
1236
2422
|
session.messages.push(message);
|
|
1237
2423
|
if (sessionLog) appendAdvisorMessage(sessionLog, session.id, message);
|
|
2424
|
+
this.persistSession(session);
|
|
1238
2425
|
this.ctx.emit("advisor-group/message", {
|
|
1239
2426
|
sessionId: session.id,
|
|
1240
2427
|
message
|
|
@@ -1246,17 +2433,38 @@ var AdvisorGroupService = class {
|
|
|
1246
2433
|
* calling ask_advisors again with sessionId + followUp, never by the
|
|
1247
2434
|
* advisor models talking to themselves back-to-back.
|
|
1248
2435
|
*/
|
|
1249
|
-
async runOneRoundAndSummarize(session, signal, sessionLog) {
|
|
1250
|
-
|
|
1251
|
-
session.
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
summary
|
|
1258
|
-
|
|
1259
|
-
|
|
2436
|
+
async runOneRoundAndSummarize(session, signal, sessionLog, agent) {
|
|
2437
|
+
const stop = new AbortController();
|
|
2438
|
+
this.activeAborts.set(session.id, stop);
|
|
2439
|
+
const combined = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal;
|
|
2440
|
+
try {
|
|
2441
|
+
await this.runOneRound(session, combined, sessionLog, agent);
|
|
2442
|
+
session.status = "completed";
|
|
2443
|
+
session.updatedAt = Date.now();
|
|
2444
|
+
const summary = this.buildSummary(session, false);
|
|
2445
|
+
if (sessionLog) appendAdvisorEnd(sessionLog, session, summary);
|
|
2446
|
+
this.persistSession(session);
|
|
2447
|
+
await this.flushLog(sessionLog);
|
|
2448
|
+
this.ctx.emit("advisor-group/session-end", {
|
|
2449
|
+
sessionId: session.id,
|
|
2450
|
+
summary
|
|
2451
|
+
});
|
|
2452
|
+
return summary;
|
|
2453
|
+
} catch (error) {
|
|
2454
|
+
if (isAbortError(error, combined) || stop.signal.aborted) {
|
|
2455
|
+
if (stop.signal.aborted) session.stopReason = "user-stop";
|
|
2456
|
+
else if (signal?.aborted) session.stopReason = "exec-cancel";
|
|
2457
|
+
else session.stopReason = "abort-error";
|
|
2458
|
+
session.status = "cancelled";
|
|
2459
|
+
this.persistSession(session);
|
|
2460
|
+
const summary = await this.generateFinalSummary(session, void 0, sessionLog, true);
|
|
2461
|
+
await this.flushLog(sessionLog);
|
|
2462
|
+
return summary;
|
|
2463
|
+
}
|
|
2464
|
+
throw error;
|
|
2465
|
+
} finally {
|
|
2466
|
+
this.activeAborts.delete(session.id);
|
|
2467
|
+
}
|
|
1260
2468
|
}
|
|
1261
2469
|
getRoundCount(session) {
|
|
1262
2470
|
const advisorCount = Math.max(1, session.advisors.length);
|
|
@@ -1271,21 +2479,21 @@ var AdvisorGroupService = class {
|
|
|
1271
2479
|
return this.config.advisors.filter((advisor) => advisorIds.includes(advisor.id));
|
|
1272
2480
|
}
|
|
1273
2481
|
buildTranscript(session) {
|
|
1274
|
-
return session.messages.filter((message) => message.role !== "system").slice(-
|
|
2482
|
+
return session.messages.filter((message) => message.role !== "system").slice(-12).map((message) => {
|
|
1275
2483
|
if (message.role === "main") return {
|
|
1276
2484
|
role: "main",
|
|
1277
2485
|
name: "主模型",
|
|
1278
|
-
content: message.content.slice(0,
|
|
2486
|
+
content: message.content.slice(0, 3e3)
|
|
1279
2487
|
};
|
|
1280
2488
|
if (message.role === "advisor" && message.advisorName) return {
|
|
1281
2489
|
role: "advisor",
|
|
1282
2490
|
name: message.advisorName,
|
|
1283
|
-
content: message.content.slice(0,
|
|
2491
|
+
content: message.content.slice(0, 3e3)
|
|
1284
2492
|
};
|
|
1285
2493
|
return null;
|
|
1286
2494
|
}).filter((entry) => entry !== null);
|
|
1287
2495
|
}
|
|
1288
|
-
async callAdvisor(session, advisor, transcript, round, joinIndex, signal, sessionLog) {
|
|
2496
|
+
async callAdvisor(session, advisor, transcript, round, joinIndex, signal, sessionLog, agent) {
|
|
1289
2497
|
const relational = { ...advisor };
|
|
1290
2498
|
relational.systemPrompt = `${advisor.systemPrompt}\n${advisorJoinPrompt(joinIndex, session.advisors.length)}`;
|
|
1291
2499
|
const message = {
|
|
@@ -1323,18 +2531,21 @@ var AdvisorGroupService = class {
|
|
|
1323
2531
|
pendingText = "";
|
|
1324
2532
|
pendingThinking = "";
|
|
1325
2533
|
};
|
|
1326
|
-
const emitDelta = (delta) => {
|
|
2534
|
+
const emitDelta = (delta, phase = "answer") => {
|
|
1327
2535
|
if (delta.text) {
|
|
1328
2536
|
message.content += delta.text;
|
|
1329
2537
|
pendingText += delta.text;
|
|
1330
2538
|
}
|
|
1331
2539
|
if (delta.thinking) {
|
|
1332
2540
|
message.thinking = (message.thinking ?? "") + delta.thinking;
|
|
2541
|
+
thinkingBuffer += delta.thinking;
|
|
1333
2542
|
pendingThinking += delta.thinking;
|
|
1334
2543
|
}
|
|
1335
2544
|
publish(session.id, {
|
|
1336
2545
|
advisorId: advisor.id,
|
|
1337
2546
|
advisorName: advisor.name,
|
|
2547
|
+
round,
|
|
2548
|
+
phase,
|
|
1338
2549
|
contentDelta: delta.text,
|
|
1339
2550
|
thinkingDelta: delta.thinking,
|
|
1340
2551
|
done: false
|
|
@@ -1342,28 +2553,105 @@ var AdvisorGroupService = class {
|
|
|
1342
2553
|
if (!timer) timer = setTimeout(flush, 200);
|
|
1343
2554
|
};
|
|
1344
2555
|
const advisorTimeoutMs = this.config.discussion.advisorTimeoutMs;
|
|
2556
|
+
let truncated;
|
|
2557
|
+
const thinkingSegments = [];
|
|
2558
|
+
let thinkingBuffer = "";
|
|
2559
|
+
const actionDescriptions = [];
|
|
1345
2560
|
if (!isDshLlmProvider || advisor.baseURL || advisor.apiKey || advisor.apiKeyEnv) {
|
|
1346
|
-
const
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
2561
|
+
const toolMode = advisor.tools ?? this.config.discussion.advisorTools ?? "readonly";
|
|
2562
|
+
const toolSchemas = resolveAdvisorToolSchemas(this.ctx, agent, toolMode);
|
|
2563
|
+
if (toolSchemas.length > 0) {
|
|
2564
|
+
relational.systemPrompt = `${relational.systemPrompt}${ADVISOR_TOOL_GUIDANCE}`;
|
|
2565
|
+
const messageToolSteps = [];
|
|
2566
|
+
let narrationBuffer = "";
|
|
2567
|
+
const emitToolStep = (step) => {
|
|
2568
|
+
const full = {
|
|
2569
|
+
...step,
|
|
2570
|
+
atMs: Date.now()
|
|
2571
|
+
};
|
|
2572
|
+
const parts = [];
|
|
2573
|
+
if (narrationBuffer.trim()) {
|
|
2574
|
+
parts.push(narrationBuffer.trim());
|
|
2575
|
+
narrationBuffer = "";
|
|
2576
|
+
}
|
|
2577
|
+
if (thinkingBuffer.trim()) parts.push(thinkingBuffer);
|
|
2578
|
+
if (parts.length > 0) {
|
|
2579
|
+
thinkingSegments.push(parts.join("\n\n"));
|
|
2580
|
+
thinkingBuffer = "";
|
|
2581
|
+
}
|
|
2582
|
+
messageToolSteps.push(full);
|
|
2583
|
+
if (sessionLog) appendAdvisorDelta(sessionLog, session.id, {
|
|
2584
|
+
sessionId: session.id,
|
|
2585
|
+
advisorId: advisor.id,
|
|
2586
|
+
advisorName: advisor.name,
|
|
2587
|
+
round,
|
|
2588
|
+
toolStep: full
|
|
2589
|
+
});
|
|
2590
|
+
publish(session.id, {
|
|
2591
|
+
advisorId: advisor.id,
|
|
2592
|
+
advisorName: advisor.name,
|
|
2593
|
+
round,
|
|
2594
|
+
toolStep: full
|
|
2595
|
+
});
|
|
2596
|
+
};
|
|
2597
|
+
const streamOnce = (tools, extra) => {
|
|
2598
|
+
const withExtra = extra.trim() ? [...transcript, {
|
|
2599
|
+
role: "main",
|
|
2600
|
+
name: "已执行工具",
|
|
2601
|
+
content: extra.trim()
|
|
2602
|
+
}] : transcript;
|
|
2603
|
+
const parseNames = toolSchemas.map((tool) => tool.name);
|
|
2604
|
+
const emitRoundDelta = (delta) => emitDelta(delta, tools.length > 0 ? "tool" : "answer");
|
|
2605
|
+
return streamDirectHttp(relational, withExtra, emitRoundDelta, signal, advisorTimeoutMs, tools, parseNames).then((result) => {
|
|
2606
|
+
narrationBuffer = result.content.trim();
|
|
2607
|
+
if (tools.length > 0 && narrationBuffer) actionDescriptions.push(narrationBuffer);
|
|
2608
|
+
return {
|
|
2609
|
+
content: result.content,
|
|
2610
|
+
thinking: result.thinking,
|
|
2611
|
+
toolCalls: result.toolCalls ?? [],
|
|
2612
|
+
truncated: result.truncated
|
|
2613
|
+
};
|
|
2614
|
+
});
|
|
2615
|
+
};
|
|
2616
|
+
const loop = await runAdvisorToolLoop(toolSchemas, streamOnce, (call) => executeAdvisorTool(this.ctx, agent, call, signal), (step) => emitToolStep(step));
|
|
2617
|
+
flush();
|
|
2618
|
+
content = loop.content;
|
|
2619
|
+
truncated = loop.truncated;
|
|
2620
|
+
message.toolSteps = messageToolSteps;
|
|
2621
|
+
} else {
|
|
2622
|
+
const streamResult = await streamDirectHttp(relational, transcript, emitDelta, signal, advisorTimeoutMs);
|
|
2623
|
+
flush();
|
|
2624
|
+
content = streamResult.content;
|
|
2625
|
+
message.thinking = streamResult.thinking;
|
|
2626
|
+
truncated = streamResult.truncated;
|
|
2627
|
+
}
|
|
1350
2628
|
} else {
|
|
1351
|
-
|
|
2629
|
+
const result = await callViaCtxLlm(this.ctx, relational, transcript, signal, emitDelta, advisorTimeoutMs);
|
|
1352
2630
|
flush();
|
|
2631
|
+
content = result.content;
|
|
2632
|
+
truncated = result.truncated;
|
|
1353
2633
|
}
|
|
1354
|
-
message.content = content;
|
|
2634
|
+
message.content = sanitizeAdvisorContent(content);
|
|
2635
|
+
if (truncated) message.truncated = truncated;
|
|
2636
|
+
if (thinkingBuffer.trim()) thinkingSegments.push(thinkingBuffer);
|
|
2637
|
+
if (thinkingSegments.length > 0) message.thinkingSegments = thinkingSegments;
|
|
2638
|
+
if (actionDescriptions.length > 0) message.actionDescriptions = actionDescriptions;
|
|
1355
2639
|
} catch (error) {
|
|
1356
2640
|
if (isAbortError(error, signal)) throw error;
|
|
1357
|
-
|
|
2641
|
+
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
2642
|
+
console.warn("[dsh-advisor-group] 顾问调用失败:", session.id, advisor.name, rawMessage);
|
|
2643
|
+
message.content = `(顾问调用失败:${this.summarizeAdvisorError(rawMessage)})`;
|
|
1358
2644
|
}
|
|
1359
2645
|
publish(session.id, {
|
|
1360
2646
|
advisorId: advisor.id,
|
|
1361
2647
|
advisorName: advisor.name,
|
|
2648
|
+
round,
|
|
1362
2649
|
done: true
|
|
1363
2650
|
});
|
|
1364
2651
|
session.messages.push(message);
|
|
1365
2652
|
if (sessionLog) appendAdvisorMessage(sessionLog, session.id, message);
|
|
1366
2653
|
session.updatedAt = Date.now();
|
|
2654
|
+
this.persistSession(session);
|
|
1367
2655
|
this.ctx.emit("advisor-group/message", {
|
|
1368
2656
|
sessionId: session.id,
|
|
1369
2657
|
message
|
|
@@ -1379,7 +2667,7 @@ var AdvisorGroupService = class {
|
|
|
1379
2667
|
}
|
|
1380
2668
|
const advisors = [...byAdvisor.entries()].map(([id, messages]) => {
|
|
1381
2669
|
const advisor = session.advisors.find((item) => item.id === id);
|
|
1382
|
-
const meaningful = messages.filter((message) => message.content && message.content.trim() && !message.content.startsWith("(顾问调用失败"));
|
|
2670
|
+
const meaningful = messages.filter((message) => message.content && message.content.trim() && !message.content.startsWith("(顾问调用失败") && !message.content.startsWith("(顾问未生成正文"));
|
|
1383
2671
|
const last = meaningful[meaningful.length - 1] ?? messages[messages.length - 1];
|
|
1384
2672
|
return {
|
|
1385
2673
|
advisorId: id,
|
|
@@ -1387,8 +2675,7 @@ var AdvisorGroupService = class {
|
|
|
1387
2675
|
corePoints: last ? this.extractCorePoints(last.content) : []
|
|
1388
2676
|
};
|
|
1389
2677
|
});
|
|
1390
|
-
const riskNotes = advisorMessages
|
|
1391
|
-
if (cancelled) riskNotes.push("本次顾问群讨论已被用户或主模型取消,结论可能不完整。");
|
|
2678
|
+
const riskNotes = buildRiskNotes(advisorMessages, cancelled);
|
|
1392
2679
|
return {
|
|
1393
2680
|
sessionId: session.id,
|
|
1394
2681
|
question: session.question,
|
|
@@ -1743,267 +3030,1514 @@ function matchAdvisors(text, advisors) {
|
|
|
1743
3030
|
return [...matches];
|
|
1744
3031
|
}
|
|
1745
3032
|
//#endregion
|
|
1746
|
-
//#region src/
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
3033
|
+
//#region src/providers/jev.ts
|
|
3034
|
+
const DEFAULT_ENDPOINTS = {
|
|
3035
|
+
typesafe: "https://api.typesafe.ai/v1/systemone",
|
|
3036
|
+
openrouter: "https://openrouter.ai/api/alpha/decisions"
|
|
3037
|
+
};
|
|
3038
|
+
function numberValue(value) {
|
|
3039
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
3040
|
+
}
|
|
3041
|
+
function answerIsTrue(answer, threshold) {
|
|
3042
|
+
const noul = numberValue(answer?.noul);
|
|
3043
|
+
if (noul !== void 0) return noul >= threshold;
|
|
3044
|
+
const choice = typeof answer?.choice === "string" ? answer.choice.toLowerCase() : "";
|
|
3045
|
+
if (choice === "yes" || choice === "true") return true;
|
|
3046
|
+
if (choice === "no" || choice === "false") return false;
|
|
3047
|
+
return false;
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* An answer is usable only when it carries a finite noul or an explicit
|
|
3051
|
+
* yes/no choice. Anything else (missing key, string noul, "maybe") would be
|
|
3052
|
+
* read as `false` by answerIsTrue — which for needs_advisor/high_risk means a
|
|
3053
|
+
* silent rejection with no fallback to the local classifier.
|
|
3054
|
+
*/
|
|
3055
|
+
function answerUsable(answer) {
|
|
3056
|
+
if (numberValue(answer?.noul) !== void 0) return true;
|
|
3057
|
+
const choice = typeof answer?.choice === "string" ? answer.choice.toLowerCase() : "";
|
|
3058
|
+
return choice === "yes" || choice === "true" || choice === "no" || choice === "false";
|
|
3059
|
+
}
|
|
3060
|
+
function parseResponse(body, config, advisors) {
|
|
3061
|
+
if (!body || typeof body !== "object") throw new Error("Jev 响应不是对象");
|
|
3062
|
+
const answers = body.answers;
|
|
3063
|
+
if (!answers || typeof answers !== "object") throw new Error("Jev 响应缺少 answers");
|
|
3064
|
+
const needsAdvisor = answers.needs_advisor;
|
|
3065
|
+
const webSearch = answers.web_search;
|
|
3066
|
+
const risk = answers.high_risk;
|
|
3067
|
+
const domainAnswer = answers.domain;
|
|
3068
|
+
if (!answerUsable(needsAdvisor)) throw new Error("Jev 响应缺少 needs_advisor 的有效判定");
|
|
3069
|
+
if (!answerUsable(risk)) throw new Error("Jev 响应缺少 high_risk 的有效判定");
|
|
3070
|
+
const domain = typeof domainAnswer?.choice === "string" ? domainAnswer.choice : "general";
|
|
3071
|
+
const needsAdvisorNoul = numberValue(needsAdvisor?.noul);
|
|
3072
|
+
const confidence = numberValue(needsAdvisor?.confidence) ?? needsAdvisorNoul;
|
|
3073
|
+
const shouldEscalate = answerIsTrue(needsAdvisor, config.confidenceThreshold);
|
|
3074
|
+
const suggestWebSearch = answerIsTrue(webSearch, config.confidenceThreshold);
|
|
3075
|
+
const highRisk = answerIsTrue(risk, config.highRiskThreshold ?? config.confidenceThreshold);
|
|
3076
|
+
const reportedModel = body.model;
|
|
3077
|
+
const webSearchNoul = numberValue(webSearch?.noul);
|
|
3078
|
+
const riskNoul = numberValue(risk?.noul);
|
|
3079
|
+
const rawAnswers = {
|
|
3080
|
+
...needsAdvisorNoul === void 0 ? {} : { needsAdvisor: needsAdvisorNoul },
|
|
3081
|
+
...webSearchNoul === void 0 ? {} : { webSearch: webSearchNoul },
|
|
3082
|
+
...riskNoul === void 0 ? {} : { highRisk: riskNoul },
|
|
3083
|
+
domain
|
|
3084
|
+
};
|
|
3085
|
+
return {
|
|
3086
|
+
shouldEscalate: shouldEscalate || highRisk,
|
|
3087
|
+
suggestWebSearch: !shouldEscalate && !highRisk && suggestWebSearch,
|
|
3088
|
+
suggestedAdvisors: matchAdvisors(`${domain}\n${domainAnswer?.choice ?? ""}`, advisors),
|
|
3089
|
+
reason: highRisk ? "Jev 判断该问题包含高风险因素,建议启动顾问群。" : shouldEscalate ? `Jev 判断该问题需要顾问群(${confidence === void 0 ? "无置信度" : `置信度 ${confidence}`})。` : suggestWebSearch ? "Jev 判断该问题更适合联网搜索。" : "Jev 判断该问题不需要启动顾问群。",
|
|
3090
|
+
...confidence === void 0 ? {} : { confidence },
|
|
3091
|
+
rawAnswers,
|
|
3092
|
+
...typeof reportedModel === "string" ? { model: reportedModel } : {},
|
|
3093
|
+
provider: config.provider
|
|
3094
|
+
};
|
|
3095
|
+
}
|
|
3096
|
+
async function classifyWithJev(question, context, advisors, config, signal, questionEn) {
|
|
3097
|
+
const endpoint = config.baseURL?.trim() || DEFAULT_ENDPOINTS[config.provider];
|
|
3098
|
+
const storedKey = typeof config.apiKey === "string" ? config.apiKey : config.apiKey && typeof config.apiKey === "object" && "value" in config.apiKey ? String(config.apiKey.value ?? "") : void 0;
|
|
3099
|
+
const apiKey = (config.apiKeyEnv ? process.env[config.apiKeyEnv] : void 0) || storedKey;
|
|
3100
|
+
if (!apiKey) throw new Error("Jev 未配置 API key");
|
|
3101
|
+
const controller = new AbortController();
|
|
3102
|
+
const timeout = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`Jev 超时(${config.timeoutMs}ms)`)), config.timeoutMs);
|
|
3103
|
+
const abort = () => controller.abort();
|
|
3104
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
3105
|
+
try {
|
|
3106
|
+
const response = await fetch(endpoint, {
|
|
3107
|
+
method: "POST",
|
|
3108
|
+
headers: {
|
|
3109
|
+
authorization: `Bearer ${apiKey}`,
|
|
3110
|
+
"content-type": "application/json"
|
|
1772
3111
|
},
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
3112
|
+
body: JSON.stringify({
|
|
3113
|
+
state: config.useEnglishState && questionEn?.trim() ? questionEn.trim() : `${question}\n\n背景:${context || "无"}`,
|
|
3114
|
+
model: config.model || "jev-latest",
|
|
3115
|
+
questions: {
|
|
3116
|
+
needs_advisor: {
|
|
3117
|
+
type: "noul",
|
|
3118
|
+
instructions: "Does answering this question require expert-level knowledge?",
|
|
3119
|
+
criteria: {
|
|
3120
|
+
true: {
|
|
3121
|
+
what: "The subject matter is professional, specialist, or long-tail knowledge a competent generalist would not reliably get right, or the field itself has no single settled answer without specialist judgment.",
|
|
3122
|
+
examples: [
|
|
3123
|
+
"professional-domain judgment such as accounting treatment, engineering trade-offs, or contract drafting",
|
|
3124
|
+
"rare or long-tail situations with little public consensus",
|
|
3125
|
+
"topics where reasonable experts disagree and picking correctly matters"
|
|
3126
|
+
]
|
|
3127
|
+
},
|
|
3128
|
+
false: {
|
|
3129
|
+
what: "A competent generalist can answer from common knowledge, widely documented facts, or ordinary reasoning without specialist judgment.",
|
|
3130
|
+
examples: ["everyday facts, definitions, or casual opinions", "well-documented mainstream topics with an agreed answer"]
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
1786
3133
|
},
|
|
1787
|
-
|
|
1788
|
-
type: "
|
|
1789
|
-
|
|
3134
|
+
web_search: {
|
|
3135
|
+
type: "noul",
|
|
3136
|
+
instructions: "Does answering this question primarily require current, real-time, or externally verifiable information?",
|
|
3137
|
+
criteria: {
|
|
3138
|
+
true: {
|
|
3139
|
+
what: "The correct answer depends on facts that change over time or live outside the provided state, so it must be looked up rather than reasoned out.",
|
|
3140
|
+
examples: [
|
|
3141
|
+
"today’s weather, prices, exchange rates, or quotes",
|
|
3142
|
+
"recent releases, news, or version-specific changelogs",
|
|
3143
|
+
"local or niche facts with no authoritative source in memory"
|
|
3144
|
+
]
|
|
3145
|
+
},
|
|
3146
|
+
false: {
|
|
3147
|
+
what: "The answer follows from stable knowledge or reasoning already available in the state.",
|
|
3148
|
+
examples: ["concepts, definitions, and established best practices", "questions fully answerable from the provided background"]
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
1790
3151
|
},
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
if (withoutModel.length > 0) return {
|
|
1822
|
-
sessionId: "",
|
|
1823
|
-
advice: `顾问群中有 ${withoutModel.length} 位顾问未配置模型(${withoutModel.map((a) => a.name).join("、")})。请先到 设置 → 插件 → 顾问群 补全模型后再发起,避免整轮调用失败。`,
|
|
1824
|
-
skipped: true,
|
|
1825
|
-
reason: "advisor-model-missing"
|
|
1826
|
-
};
|
|
1827
|
-
const sessionLog = exec.agent?.session;
|
|
1828
|
-
const questionText = typeof args.question === "string" ? args.question : "";
|
|
1829
|
-
const contextText = typeof args.context === "string" ? args.context : "";
|
|
1830
|
-
const mentionedAdvisorGroup = /@顾问群|@顧問群/.test(`${questionText}\n${contextText}`);
|
|
1831
|
-
const forcedByRepeat = (service.getRepeatPressure(sessionLog?.id)?.count ?? 0) >= 3;
|
|
1832
|
-
if (args.sessionId) {
|
|
1833
|
-
if (!args.followUp) return {
|
|
1834
|
-
sessionId: args.sessionId,
|
|
1835
|
-
advice: "继续追问需要提供 followUp 参数。",
|
|
1836
|
-
skipped: true,
|
|
1837
|
-
reason: "missing-follow-up"
|
|
1838
|
-
};
|
|
1839
|
-
const session = service.getSession(args.sessionId);
|
|
1840
|
-
if (!session) return {
|
|
1841
|
-
sessionId: args.sessionId,
|
|
1842
|
-
advice: "顾问群会话不存在或已过期,请重新发起 ask_advisors。",
|
|
1843
|
-
skipped: true,
|
|
1844
|
-
reason: "session-not-found"
|
|
1845
|
-
};
|
|
1846
|
-
if (service.hasReachedMaxRounds(session)) return {
|
|
1847
|
-
sessionId: session.id,
|
|
1848
|
-
advice: "本轮已是顾问群讨论的最后一轮,请综合现有顾问意见给出最终答复,不要再追问。",
|
|
1849
|
-
skipped: true,
|
|
1850
|
-
reason: "max-rounds-reached"
|
|
1851
|
-
};
|
|
1852
|
-
service.appendFollowUp(session.id, args.followUp, sessionLog);
|
|
1853
|
-
await service.runOneRoundAndSummarize(session, exec.signal, sessionLog);
|
|
1854
|
-
const canContinue = !service.hasReachedMaxRounds(session);
|
|
1855
|
-
return {
|
|
1856
|
-
sessionId: session.id,
|
|
1857
|
-
advice: formatConversation(session) + (canContinue ? "\n\n(如需继续,可再次传入 sessionId 和 followUp 追问。)" : "\n\n(讨论轮数已达上限,请综合顾问意见给出最终答复。)"),
|
|
1858
|
-
skipped: false
|
|
1859
|
-
};
|
|
1860
|
-
}
|
|
1861
|
-
if (!args.question) return {
|
|
1862
|
-
sessionId: "",
|
|
1863
|
-
advice: "发起新讨论需要提供 question 参数。",
|
|
1864
|
-
skipped: true,
|
|
1865
|
-
reason: "missing-question"
|
|
1866
|
-
};
|
|
1867
|
-
if (cfg.trigger.requireClassifier && !mentionedAdvisorGroup && !forcedByRepeat) {
|
|
1868
|
-
const classification = classifyRequest(args.question, args.context, cfg.advisors, args.confidence, cfg.trigger.confidenceThreshold);
|
|
1869
|
-
appendShadowSample({
|
|
1870
|
-
ts: Date.now(),
|
|
1871
|
-
question: args.question,
|
|
1872
|
-
...typeof args.confidence === "number" ? { confidence: args.confidence } : {},
|
|
1873
|
-
shouldEscalate: classification.shouldEscalate,
|
|
1874
|
-
reason: classification.reason,
|
|
1875
|
-
suggestWebSearch: classification.suggestWebSearch,
|
|
1876
|
-
launched: classification.shouldEscalate
|
|
1877
|
-
});
|
|
1878
|
-
if (!classification.shouldEscalate) {
|
|
1879
|
-
if (classification.suggestWebSearch && cfg.trigger.allowWebFallback) return {
|
|
1880
|
-
sessionId: "",
|
|
1881
|
-
advice: `前置分类器建议不启动顾问群:${classification.reason}`,
|
|
1882
|
-
skipped: true,
|
|
1883
|
-
reason: "web-search-recommended"
|
|
1884
|
-
};
|
|
1885
|
-
return {
|
|
1886
|
-
sessionId: "",
|
|
1887
|
-
advice: `前置分类器建议不启动顾问群:${classification.reason}`,
|
|
1888
|
-
skipped: true,
|
|
1889
|
-
reason: "classifier-rejected"
|
|
1890
|
-
};
|
|
3152
|
+
high_risk: {
|
|
3153
|
+
type: "noul",
|
|
3154
|
+
instructions: "Does this question involve medical, legal, financial, safety, compliance, or other high-risk advice?",
|
|
3155
|
+
criteria: {
|
|
3156
|
+
true: {
|
|
3157
|
+
what: "A wrong or careless answer could materially harm the asker or a third party in health, money, safety, or legal standing.",
|
|
3158
|
+
examples: [
|
|
3159
|
+
"medical symptoms, medication, diagnosis, or treatment decisions",
|
|
3160
|
+
"legal rights, contracts, litigation, or regulatory compliance",
|
|
3161
|
+
"investment, insurance, tax, or other money decisions with real downside",
|
|
3162
|
+
"physical safety, hazardous actions, or regulated activities"
|
|
3163
|
+
]
|
|
3164
|
+
},
|
|
3165
|
+
false: {
|
|
3166
|
+
what: "Being wrong carries no material harm to health, money, safety, or legal standing.",
|
|
3167
|
+
examples: ["general knowledge, opinions, or entertainment questions", "technical details whose worst case is wasted time"]
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
},
|
|
3171
|
+
domain: {
|
|
3172
|
+
type: "choice",
|
|
3173
|
+
instructions: "Which domain best describes this question?",
|
|
3174
|
+
criteria: {
|
|
3175
|
+
legal: "law, contracts, litigation, compliance, or taxation",
|
|
3176
|
+
medical: "medicine, symptoms, medication, diagnosis, or treatment",
|
|
3177
|
+
finance: "investment, insurance, accounting, or financial risk",
|
|
3178
|
+
code: "software, programming, architecture, debugging, or APIs",
|
|
3179
|
+
general: "none of the listed domains"
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
1891
3182
|
}
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
};
|
|
1901
|
-
}
|
|
1902
|
-
const costGuard = service.tryStartConsultation();
|
|
1903
|
-
if (!costGuard.ok) return {
|
|
1904
|
-
sessionId: "",
|
|
1905
|
-
advice: costGuard.reason,
|
|
1906
|
-
skipped: true,
|
|
1907
|
-
reason: "daily-limit-reached"
|
|
1908
|
-
};
|
|
1909
|
-
const session = service.createSession(args.question, args.context, args.advisorIds);
|
|
1910
|
-
if (sessionLog) appendAdvisorStart(sessionLog, session);
|
|
1911
|
-
const summary = await service.runAutoPipeline(session, exec.signal, sessionLog);
|
|
1912
|
-
return {
|
|
1913
|
-
sessionId: session.id,
|
|
1914
|
-
advice: formatConversation(session) + (summary.conclusion ? `\n\n【综合结论】\n${summary.conclusion}` : "") + (service.hasReachedMaxRounds(session) ? "\n\n(已达设置的最大讨论轮数,请参考上述顾问意见与综合结论给出最终答复。)" : ""),
|
|
1915
|
-
skipped: false
|
|
1916
|
-
};
|
|
1917
|
-
}
|
|
1918
|
-
}), defineTool({
|
|
1919
|
-
name: "toggle_advisor_group",
|
|
1920
|
-
description: "Enable or disable the advisor group plugin at runtime. Omit enabled to toggle the current state.",
|
|
1921
|
-
parameters: { enabled: {
|
|
1922
|
-
type: "boolean",
|
|
1923
|
-
description: "Optional desired state."
|
|
1924
|
-
} },
|
|
1925
|
-
output: {
|
|
1926
|
-
schema: {
|
|
1927
|
-
type: "object",
|
|
1928
|
-
additionalProperties: false,
|
|
1929
|
-
properties: { enabled: {
|
|
1930
|
-
type: "boolean",
|
|
1931
|
-
required: true
|
|
1932
|
-
} }
|
|
1933
|
-
},
|
|
1934
|
-
render: (_args, value) => [{
|
|
1935
|
-
type: "text",
|
|
1936
|
-
text: `顾问群插件已${value.enabled ? "启用" : "禁用"}。`
|
|
1937
|
-
}]
|
|
1938
|
-
},
|
|
1939
|
-
async execute(args) {
|
|
1940
|
-
const next = typeof args.enabled === "boolean" ? args.enabled : !service.isEnabled();
|
|
1941
|
-
return { enabled: await service.toggleEnabled(next) };
|
|
1942
|
-
}
|
|
1943
|
-
})];
|
|
1944
|
-
}
|
|
1945
|
-
function formatConversation(session) {
|
|
1946
|
-
const lines = [`顾问群对话(session: ${session.id})`, ""];
|
|
1947
|
-
for (const message of session.messages) {
|
|
1948
|
-
if (message.role === "system") continue;
|
|
1949
|
-
if (message.role === "main") lines.push(`主模型:${message.content}`);
|
|
1950
|
-
else if (message.role === "advisor") {
|
|
1951
|
-
const body = message.content && message.content.trim() ? message.content : message.thinking && message.thinking.trim() ? `(思维链)\n${message.thinking}` : "(顾问未返回正文)";
|
|
1952
|
-
lines.push(`【${message.advisorName ?? message.advisorId ?? "顾问"}】:${body}`);
|
|
1953
|
-
}
|
|
1954
|
-
lines.push("");
|
|
3183
|
+
}),
|
|
3184
|
+
signal: controller.signal
|
|
3185
|
+
});
|
|
3186
|
+
if (!response.ok) throw new Error(`Jev HTTP ${response.status}`);
|
|
3187
|
+
return parseResponse(await response.json(), config, advisors);
|
|
3188
|
+
} finally {
|
|
3189
|
+
clearTimeout(timeout);
|
|
3190
|
+
signal?.removeEventListener("abort", abort);
|
|
1955
3191
|
}
|
|
1956
|
-
lines.push("(以上是顾问模型与主模型的对话记录,主模型可直接基于其中内容继续思考或追问。)");
|
|
1957
|
-
return lines.join("\n");
|
|
1958
3192
|
}
|
|
1959
3193
|
//#endregion
|
|
1960
|
-
//#region src/
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
3194
|
+
//#region src/cross-session.ts
|
|
3195
|
+
/**
|
|
3196
|
+
* Cross-session ask (v0): let the current session deliver one question to
|
|
3197
|
+
* another existing ordinary session, wake it, and report what that session
|
|
3198
|
+
* said between the delivery receipt and its next whole-session idle.
|
|
3199
|
+
*
|
|
3200
|
+
* Facts this module is built on (DSH 0.2.0-rc.1/rc.2 types, verified):
|
|
3201
|
+
* - `ctx.sessionController.resolveAgent(sessionId)` yields the live Agent
|
|
3202
|
+
* (`{ agent }` or `{ error }`), resuming a cold session.
|
|
3203
|
+
* - `agent.followup(createUserMessage({ content, source }))` queues one
|
|
3204
|
+
* ordinary follow-up turn and wakes the driver.
|
|
3205
|
+
* - `ctx.sessionController.prompt({ sessionId, content, mode:'queue', requestId })`
|
|
3206
|
+
* is the cheap fallback, but the Host hardcodes `source` to a user prompt, so
|
|
3207
|
+
* that path is identity-dishonest and must be reported as such.
|
|
3208
|
+
* - There is NO API that returns "the answer to this message". The Host only
|
|
3209
|
+
* guarantees delivery, so this module reports an INTERVAL, never a reply.
|
|
3210
|
+
* - The Host enforces no caller identity, no cycle guard, and hard-excludes
|
|
3211
|
+
* subagent sessions, so every guard in `ask_session` lives here.
|
|
3212
|
+
*
|
|
3213
|
+
* The pure helpers below never touch `ctx`; only `askSession()` does I/O.
|
|
3214
|
+
*/
|
|
3215
|
+
/** Maximum combined UTF-8 bytes of question + context (design §六.5). */
|
|
3216
|
+
const ASK_CONTENT_MAX_BYTES = 32e3;
|
|
3217
|
+
/** Default and maximum wait for the target's next idle (design §十). */
|
|
3218
|
+
const ASK_WAIT_DEFAULT_MS = 18e4;
|
|
3219
|
+
const ASK_WAIT_MAX_MS = 6e5;
|
|
3220
|
+
/** Poll interval while waiting for the target to reach whole-session idle. */
|
|
3221
|
+
const ASK_POLL_INTERVAL_MS = 1e3;
|
|
3222
|
+
/**
|
|
3223
|
+
* Envelope the receiving model reads. The Host offers no registration point for
|
|
3224
|
+
* this framing (the official Agent Teams build their own too), so the identity
|
|
3225
|
+
* is carried in the content itself: a header block naming the asking session,
|
|
3226
|
+
* then the question, then optional background.
|
|
3227
|
+
* @param senderName - title (or cwd+time fallback) of the asking session.
|
|
3228
|
+
* @param question - the question to hand over.
|
|
3229
|
+
* @param context - optional background; permanently recorded in the target log.
|
|
3230
|
+
* @returns content blocks, envelope first.
|
|
3231
|
+
*/
|
|
3232
|
+
function buildAskEnvelope(senderName, question, context) {
|
|
3233
|
+
const blocks = [{
|
|
3234
|
+
type: "text",
|
|
3235
|
+
text: `【来自会话「${senderName}」的提问】`
|
|
3236
|
+
}, {
|
|
3237
|
+
type: "text",
|
|
3238
|
+
text: question
|
|
3239
|
+
}];
|
|
3240
|
+
if (context !== void 0 && context.trim().length > 0) blocks.push({
|
|
3241
|
+
type: "text",
|
|
3242
|
+
text: `【对方补充的背景】\n${context}`
|
|
3243
|
+
});
|
|
3244
|
+
return blocks;
|
|
1964
3245
|
}
|
|
1965
|
-
/**
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
3246
|
+
/**
|
|
3247
|
+
* Build the identified user message handed to `agent.followup()`.
|
|
3248
|
+
* @param envelope - content blocks produced by {@link buildAskEnvelope}.
|
|
3249
|
+
* @param source - producer-declared session-ask source.
|
|
3250
|
+
* @returns an immutable identified user message carrying our source kind.
|
|
3251
|
+
*/
|
|
3252
|
+
function buildAskMessage(envelope, source) {
|
|
3253
|
+
return createUserMessage({
|
|
3254
|
+
content: envelope,
|
|
3255
|
+
source
|
|
3256
|
+
});
|
|
1970
3257
|
}
|
|
1971
3258
|
/**
|
|
1972
|
-
*
|
|
1973
|
-
*
|
|
1974
|
-
*
|
|
1975
|
-
*
|
|
1976
|
-
* real key with a mask string after upgrading.
|
|
3259
|
+
* UTF-8 byte length of the text a caller wants to hand over.
|
|
3260
|
+
* @param question - the question text.
|
|
3261
|
+
* @param context - optional background text.
|
|
3262
|
+
* @returns byte length of question + context.
|
|
1977
3263
|
*/
|
|
1978
|
-
function
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
3264
|
+
function askContentBytes(question, context) {
|
|
3265
|
+
return Buffer.byteLength(question, "utf8") + (context === void 0 ? 0 : Buffer.byteLength(context, "utf8"));
|
|
3266
|
+
}
|
|
3267
|
+
/** Deterministic fallback label: working directory plus most recent activity. */
|
|
3268
|
+
function fallbackSessionName(cwd, updatedAt) {
|
|
3269
|
+
const where = cwd === void 0 || cwd.length === 0 ? "(未知工作目录)" : cwd;
|
|
3270
|
+
if (updatedAt === void 0 || !Number.isFinite(updatedAt)) return where;
|
|
3271
|
+
const at = new Date(updatedAt);
|
|
3272
|
+
return `${where} · ${Number.isNaN(at.getTime()) ? String(updatedAt) : at.toISOString()}`;
|
|
1983
3273
|
}
|
|
1984
3274
|
/**
|
|
1985
|
-
*
|
|
1986
|
-
*
|
|
1987
|
-
*
|
|
1988
|
-
* the host. `apiKey` is returned empty and per-provider presence facts
|
|
1989
|
-
* (`configured` + `last4`) ride in `apiKeyMetaByProvider`, so the card can
|
|
1990
|
-
* render the official "已配置/未配置" badge and publish new/cleared keys
|
|
1991
|
-
* without ever round-tripping a mask.
|
|
3275
|
+
* Model-visible name of one listed session. The title lives in the `title`
|
|
3276
|
+
* projection (a snapshot object), is optional, and may be a stale cache value,
|
|
3277
|
+
* so every failure degrades to the cwd+time fallback instead of throwing.
|
|
1992
3278
|
*/
|
|
1993
|
-
function
|
|
3279
|
+
function sessionDisplayName(summary) {
|
|
3280
|
+
const raw = summary.projections?.values?.title;
|
|
3281
|
+
const title = raw !== null && typeof raw === "object" && typeof raw.title === "string" ? raw.title.trim() : "";
|
|
3282
|
+
if (title.length > 0) return title;
|
|
3283
|
+
return fallbackSessionName(summary.cwd, summary.updatedAt);
|
|
3284
|
+
}
|
|
3285
|
+
function candidateOf(summary) {
|
|
1994
3286
|
return {
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
3287
|
+
sessionId: summary.sessionId,
|
|
3288
|
+
name: sessionDisplayName(summary),
|
|
3289
|
+
hint: fallbackSessionName(summary.cwd, summary.updatedAt)
|
|
3290
|
+
};
|
|
3291
|
+
}
|
|
3292
|
+
/** Most-recently-active first, running sessions ahead of idle ones. */
|
|
3293
|
+
function orderCandidates(rows) {
|
|
3294
|
+
return [...rows].sort((a, b) => {
|
|
3295
|
+
if (a.running !== b.running) return a.running ? -1 : 1;
|
|
3296
|
+
return b.updatedAt - a.updatedAt;
|
|
3297
|
+
});
|
|
3298
|
+
}
|
|
3299
|
+
/** Partial candidates are capped: a broad needle must stay a readable list. */
|
|
3300
|
+
const PARTIAL_CANDIDATE_LIMIT = 10;
|
|
3301
|
+
/**
|
|
3302
|
+
* Resolve a user/model-supplied target reference against the session list.
|
|
3303
|
+
* Ladder, in order: exact sessionId → exact display name → partial natural
|
|
3304
|
+
* name (substring of the display name OR of the workspace cwd, case-insensitive)
|
|
3305
|
+
* → missing. One hit resolves outright; several hits become candidates —
|
|
3306
|
+
* this function NEVER guesses. Partial candidates are ordered most-recently-
|
|
3307
|
+
* active first (running sessions first) and capped, so "ask the 极简遥控器
|
|
3308
|
+
* session" works without anyone digging session ids out of log directories.
|
|
3309
|
+
* @param sessions - rows from `sessionController.list()`.
|
|
3310
|
+
* @param reference - a session id, display name, or natural partial name.
|
|
3311
|
+
* @returns the unique target, the candidate list, or "missing".
|
|
3312
|
+
*/
|
|
3313
|
+
function matchTargetSession(sessions, reference) {
|
|
3314
|
+
const needle = reference.trim();
|
|
3315
|
+
if (needle.length === 0) return { kind: "missing" };
|
|
3316
|
+
const byId = sessions.find((session) => session.sessionId === needle);
|
|
3317
|
+
if (byId) return {
|
|
3318
|
+
kind: "unique",
|
|
3319
|
+
target: {
|
|
3320
|
+
sessionId: byId.sessionId,
|
|
3321
|
+
name: sessionDisplayName(byId)
|
|
3322
|
+
}
|
|
3323
|
+
};
|
|
3324
|
+
const folded = needle.toLowerCase();
|
|
3325
|
+
const exact = sessions.filter((session) => sessionDisplayName(session).toLowerCase() === folded);
|
|
3326
|
+
if (exact.length === 1) return {
|
|
3327
|
+
kind: "unique",
|
|
3328
|
+
target: {
|
|
3329
|
+
sessionId: exact[0].sessionId,
|
|
3330
|
+
name: sessionDisplayName(exact[0])
|
|
3331
|
+
}
|
|
3332
|
+
};
|
|
3333
|
+
if (exact.length > 1) return {
|
|
3334
|
+
kind: "ambiguous",
|
|
3335
|
+
candidates: orderCandidates(exact).map(candidateOf)
|
|
3336
|
+
};
|
|
3337
|
+
const partial = sessions.filter((session) => sessionDisplayName(session).toLowerCase().includes(folded) || session.cwd !== void 0 && session.cwd.toLowerCase().includes(folded));
|
|
3338
|
+
if (partial.length === 1) return {
|
|
3339
|
+
kind: "unique",
|
|
3340
|
+
target: {
|
|
3341
|
+
sessionId: partial[0].sessionId,
|
|
3342
|
+
name: sessionDisplayName(partial[0])
|
|
3343
|
+
}
|
|
3344
|
+
};
|
|
3345
|
+
if (partial.length === 0) return { kind: "missing" };
|
|
3346
|
+
return {
|
|
3347
|
+
kind: "ambiguous",
|
|
3348
|
+
candidates: orderCandidates(partial).slice(0, PARTIAL_CANDIDATE_LIMIT).map(candidateOf)
|
|
3349
|
+
};
|
|
3350
|
+
}
|
|
3351
|
+
/**
|
|
3352
|
+
* Hop guard counter: the hop of the question this session is about to send.
|
|
3353
|
+
* The value is carried by the asking message that reached this session, so it
|
|
3354
|
+
* is read from the most recent inbound `user/message` whose source is
|
|
3355
|
+
* `session-ask`; a later plain human prompt does not reset it, because the
|
|
3356
|
+
* question being asked here still descends from that relay. No session-ask
|
|
3357
|
+
* source anywhere means this is a fresh, top-level question (hop 0).
|
|
3358
|
+
* @param events - this session's log events in order.
|
|
3359
|
+
* @returns the hop to stamp on the outgoing message.
|
|
3360
|
+
*/
|
|
3361
|
+
function nextAskHop(events) {
|
|
3362
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
3363
|
+
const event = events[i];
|
|
3364
|
+
if (event.type !== "user/message") continue;
|
|
3365
|
+
const source = event.data.message?.source;
|
|
3366
|
+
if (source?.kind !== "session-ask") continue;
|
|
3367
|
+
return typeof source.hop === "number" && Number.isFinite(source.hop) ? source.hop + 1 : 1;
|
|
3368
|
+
}
|
|
3369
|
+
return 0;
|
|
3370
|
+
}
|
|
3371
|
+
/**
|
|
3372
|
+
* Collect what the target session said inside the interval. Only
|
|
3373
|
+
* `assistant/message` events strictly after the delivery watermark count, so a
|
|
3374
|
+
* reply that predates the delivery cannot be reported as an answer. The
|
|
3375
|
+
* watermark (highest seq already present at delivery) is the authoritative cut;
|
|
3376
|
+
* `fromMs` is kept only as the recorded interval start.
|
|
3377
|
+
* @param events - the target session's log events in order.
|
|
3378
|
+
* @param fromSeq - highest event seq already present at delivery time.
|
|
3379
|
+
* @returns the newest event time seen and the per-message text pieces.
|
|
3380
|
+
*/
|
|
3381
|
+
function collectAnswer(events, fromSeq) {
|
|
3382
|
+
const pieces = [];
|
|
3383
|
+
let latestEventTime = null;
|
|
3384
|
+
for (const event of events) {
|
|
3385
|
+
if (Number.isFinite(event.time) && (latestEventTime === null || event.time > latestEventTime)) latestEventTime = event.time;
|
|
3386
|
+
if (event.type !== "assistant/message") continue;
|
|
3387
|
+
if (!(event.seq > fromSeq)) continue;
|
|
3388
|
+
const text = (event.data?.message?.content ?? []).filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("").trim();
|
|
3389
|
+
if (text.length > 0) pieces.push(text);
|
|
3390
|
+
}
|
|
3391
|
+
return {
|
|
3392
|
+
latestEventTime,
|
|
3393
|
+
pieces
|
|
3394
|
+
};
|
|
3395
|
+
}
|
|
3396
|
+
/** Join the interval's text pieces into the returned answer body. */
|
|
3397
|
+
function joinAnswerPieces(pieces) {
|
|
3398
|
+
return pieces.join("\n\n");
|
|
3399
|
+
}
|
|
3400
|
+
/** Highest event seq present, or -1 for an empty log (the session-cursor convention). */
|
|
3401
|
+
function logWatermark(events) {
|
|
3402
|
+
let highest = -1;
|
|
3403
|
+
for (const event of events) if (typeof event.seq === "number" && event.seq > highest) highest = event.seq;
|
|
3404
|
+
return highest;
|
|
3405
|
+
}
|
|
3406
|
+
/** Human-readable text the main model reads for one settled ask_session call. */
|
|
3407
|
+
function renderAskResult(value) {
|
|
3408
|
+
return [{
|
|
3409
|
+
type: "text",
|
|
3410
|
+
text: `${value.answer.trim().length > 0 ? value.answer : "(该区间内对方没有产出正文。)"}\n\n${value.note}`
|
|
3411
|
+
}];
|
|
3412
|
+
}
|
|
3413
|
+
/**
|
|
3414
|
+
* The Host hard-excludes subagent sessions with `session/agent-busy` and the
|
|
3415
|
+
* reason "use subagent delivery for this child session"; recognise it so the
|
|
3416
|
+
* caller gets the actionable `subagent-target` code instead of `delivery-failed`.
|
|
3417
|
+
*/
|
|
3418
|
+
function isSubagentDeliveryRejection(error) {
|
|
3419
|
+
if (error === null || typeof error !== "object") return false;
|
|
3420
|
+
const shape = error;
|
|
3421
|
+
const reason = shape.details !== null && typeof shape.details === "object" ? shape.details.reason : void 0;
|
|
3422
|
+
const haystack = `${typeof reason === "string" ? reason : ""} ${typeof shape.message === "string" ? shape.message : ""}`;
|
|
3423
|
+
return haystack.includes("subagent delivery") || haystack.includes("owned by subagent routing");
|
|
3424
|
+
}
|
|
3425
|
+
/** One unsettled wait per session: at most one ask_session may be in flight. */
|
|
3426
|
+
const pendingWaits = /* @__PURE__ */ new Set();
|
|
3427
|
+
/** Whether this session already has an unsettled ask_session wait. */
|
|
3428
|
+
function isWaiting(sessionId) {
|
|
3429
|
+
return pendingWaits.has(sessionId);
|
|
3430
|
+
}
|
|
3431
|
+
let askCounter = 0;
|
|
3432
|
+
/** Process-unique ask id (the receiver side may dedupe on it). */
|
|
3433
|
+
function nextAskId() {
|
|
3434
|
+
askCounter += 1;
|
|
3435
|
+
return `ask-${Date.now().toString(36)}-${askCounter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3436
|
+
}
|
|
3437
|
+
/**
|
|
3438
|
+
* Lazy host-service access: never a hard `inject` (older hosts lack this service).
|
|
3439
|
+
*
|
|
3440
|
+
* A bare `ctx.sessionController` read is gated by the plugin's `inject` list and
|
|
3441
|
+
* throws `cannot get property "sessionController" without inject` when the key is
|
|
3442
|
+
* not declared — so the property read itself must not be used. `ctx.get(name)` is
|
|
3443
|
+
* Cordis's documented read "without the inject requirement": it returns the service
|
|
3444
|
+
* or `undefined` when (not yet) provided.
|
|
3445
|
+
*/
|
|
3446
|
+
function getSessionController(ctx) {
|
|
3447
|
+
if (ctx === null || ctx === void 0) return void 0;
|
|
3448
|
+
try {
|
|
3449
|
+
const context = ctx;
|
|
3450
|
+
const controller = typeof context.get === "function" ? context.get.call(ctx, "sessionController") : context.sessionController;
|
|
3451
|
+
if (controller === null || typeof controller !== "object") return void 0;
|
|
3452
|
+
const candidate = controller;
|
|
3453
|
+
if (typeof candidate.list !== "function" || typeof candidate.resolveAgent !== "function") return void 0;
|
|
3454
|
+
return candidate;
|
|
3455
|
+
} catch {
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
/** Read a live Agent's own log through a defensive view (the log API is read-only here). */
|
|
3460
|
+
function agentEvents(agent) {
|
|
3461
|
+
try {
|
|
3462
|
+
return agent.session?.snapshotEvents() ?? [];
|
|
3463
|
+
} catch {
|
|
3464
|
+
return [];
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
async function sleep(ms, signal) {
|
|
3468
|
+
if (signal.aborted) return;
|
|
3469
|
+
await new Promise((resolve) => {
|
|
3470
|
+
const timer = setTimeout(resolve, ms);
|
|
3471
|
+
signal.addEventListener("abort", () => {
|
|
3472
|
+
clearTimeout(timer);
|
|
3473
|
+
resolve();
|
|
3474
|
+
}, { once: true });
|
|
3475
|
+
});
|
|
3476
|
+
}
|
|
3477
|
+
/**
|
|
3478
|
+
* Hand the question to the target session.
|
|
3479
|
+
*
|
|
3480
|
+
* Preferred path: `resolveAgent` + `agent.followup(...)`, which preserves our
|
|
3481
|
+
* own source kind. Fallback: `sessionController.prompt(...)`, which the Host
|
|
3482
|
+
* stamps as a user prompt — the envelope still names us, and the caller is told
|
|
3483
|
+
* in `note` that this identity-dishonest path was used.
|
|
3484
|
+
*/
|
|
3485
|
+
async function deliverAsk(controller, signal, input) {
|
|
3486
|
+
const envelope = buildAskEnvelope(input.source.senderName, input.question, input.context);
|
|
3487
|
+
try {
|
|
3488
|
+
const resolved = await controller.resolveAgent(input.target.sessionId);
|
|
3489
|
+
if ("agent" in resolved && resolved.agent) {
|
|
3490
|
+
const agent = resolved.agent;
|
|
3491
|
+
agent.followup(buildAskMessage(envelope, input.source));
|
|
3492
|
+
return {
|
|
3493
|
+
ok: true,
|
|
3494
|
+
receipt: {
|
|
3495
|
+
delivered: "native",
|
|
3496
|
+
note: "本次投递走了首选通道(自定义来源),对方模型能看到这条消息来自另一个会话。",
|
|
3497
|
+
fromSeq: logWatermark(agentEvents(agent)),
|
|
3498
|
+
fromMs: Date.now(),
|
|
3499
|
+
targetWasRunning: input.targetWasRunning
|
|
3500
|
+
}
|
|
3501
|
+
};
|
|
3502
|
+
}
|
|
3503
|
+
const reason = "error" in resolved ? resolved.error : void 0;
|
|
3504
|
+
if (isSubagentDeliveryRejection(reason)) return {
|
|
3505
|
+
ok: false,
|
|
3506
|
+
code: "subagent-target",
|
|
3507
|
+
message: `目标会话 ${input.target.sessionId} 属于子代理(subagent)会话,跨会话投递被宿主硬性排除。请改用子代理通道(subagent / send_message)。`
|
|
3508
|
+
};
|
|
3509
|
+
return deliverViaPrompt(controller, signal, envelope, input, `首选通道不可用(${describeHostError(reason)})`);
|
|
3510
|
+
} catch (error) {
|
|
3511
|
+
if (signal.aborted) throw error;
|
|
3512
|
+
if (isSubagentDeliveryRejection(error)) return {
|
|
3513
|
+
ok: false,
|
|
3514
|
+
code: "subagent-target",
|
|
3515
|
+
message: `目标会话 ${input.target.sessionId} 属于子代理(subagent)会话,跨会话投递被宿主硬性排除。请改用子代理通道(subagent / send_message)。`
|
|
3516
|
+
};
|
|
3517
|
+
if (isTargetNotFound(error)) return {
|
|
3518
|
+
ok: false,
|
|
3519
|
+
code: "target-not-found",
|
|
3520
|
+
message: `找不到会话 ${input.target.sessionId}(宿主报告 session/not-found)。`
|
|
3521
|
+
};
|
|
3522
|
+
return deliverViaPrompt(controller, signal, envelope, input, `首选通道报错(${describeHostError(error)})`);
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
async function deliverViaPrompt(controller, _signal, envelope, input, why) {
|
|
3526
|
+
try {
|
|
3527
|
+
await controller.prompt({
|
|
3528
|
+
requestId: input.source.askId,
|
|
3529
|
+
sessionId: input.target.sessionId,
|
|
3530
|
+
mode: "queue",
|
|
3531
|
+
content: envelope
|
|
3532
|
+
});
|
|
3533
|
+
const floor = await watermarkViaResolve(controller, input.target.sessionId);
|
|
3534
|
+
return {
|
|
3535
|
+
ok: true,
|
|
3536
|
+
receipt: {
|
|
3537
|
+
delivered: "fallback",
|
|
3538
|
+
note: `本次投递走了降级通道(宿主的 prompt 接口),宿主把来源写死成「用户输入」,对方模型会以为这是用户打的字;身份只能靠正文里的信封说明。原因:${why}`,
|
|
3539
|
+
fromSeq: floor,
|
|
3540
|
+
fromMs: Date.now(),
|
|
3541
|
+
targetWasRunning: input.targetWasRunning
|
|
3542
|
+
}
|
|
3543
|
+
};
|
|
3544
|
+
} catch (error) {
|
|
3545
|
+
if (isSubagentDeliveryRejection(error)) return {
|
|
3546
|
+
ok: false,
|
|
3547
|
+
code: "subagent-target",
|
|
3548
|
+
message: `目标会话 ${input.target.sessionId} 属于子代理(subagent)会话,跨会话投递被宿主硬性排除。请改用子代理通道(subagent / send_message)。`
|
|
3549
|
+
};
|
|
3550
|
+
if (isTargetNotFound(error)) return {
|
|
3551
|
+
ok: false,
|
|
3552
|
+
code: "target-not-found",
|
|
3553
|
+
message: `找不到会话 ${input.target.sessionId}(宿主报告 session/not-found)。`
|
|
3554
|
+
};
|
|
3555
|
+
return {
|
|
3556
|
+
ok: false,
|
|
3557
|
+
code: "delivery-failed",
|
|
3558
|
+
message: `投递失败:首选通道与降级通道都未能把问题送进目标会话。${why} 降级通道报错:${describeHostError(error)}`
|
|
3559
|
+
};
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
async function watermarkViaResolve(controller, sessionId) {
|
|
3563
|
+
try {
|
|
3564
|
+
const resolved = await controller.resolveAgent(sessionId);
|
|
3565
|
+
if ("agent" in resolved && resolved.agent) return logWatermark(agentEvents(resolved.agent));
|
|
3566
|
+
} catch {}
|
|
3567
|
+
return -1;
|
|
3568
|
+
}
|
|
3569
|
+
function isTargetNotFound(error) {
|
|
3570
|
+
if (error === null || typeof error !== "object") return false;
|
|
3571
|
+
return error.code === "session/not-found";
|
|
3572
|
+
}
|
|
3573
|
+
function describeHostError(error) {
|
|
3574
|
+
if (error === null || error === void 0) return "未提供原因";
|
|
3575
|
+
if (typeof error === "string") return error;
|
|
3576
|
+
if (typeof error === "object") {
|
|
3577
|
+
const shape = error;
|
|
3578
|
+
const code = typeof shape.code === "string" ? shape.code : "";
|
|
3579
|
+
const message = typeof shape.message === "string" ? shape.message : "";
|
|
3580
|
+
if (code.length > 0 || message.length > 0) return `${code}${code && message ? " " : ""}${message}`;
|
|
3581
|
+
try {
|
|
3582
|
+
return JSON.stringify(error);
|
|
3583
|
+
} catch {
|
|
3584
|
+
return "宿主返回了无法序列化的错误";
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
return String(error);
|
|
3588
|
+
}
|
|
3589
|
+
/**
|
|
3590
|
+
* Wait for the target session's next whole-session idle after delivery.
|
|
3591
|
+
* Never cancels the target turn — a timeout stops only this session's waiting.
|
|
3592
|
+
*/
|
|
3593
|
+
async function waitForTargetIdle(controller, signal, input) {
|
|
3594
|
+
const startedAt = Date.now();
|
|
3595
|
+
let targetRunning = input.targetWasRunning;
|
|
3596
|
+
let observedBusy = input.targetWasRunning;
|
|
3597
|
+
const watermark = async () => {
|
|
3598
|
+
try {
|
|
3599
|
+
const resolved = await controller.resolveAgent(input.sessionId);
|
|
3600
|
+
if (!("agent" in resolved) || !resolved.agent) return void 0;
|
|
3601
|
+
return logWatermark(agentEvents(resolved.agent));
|
|
3602
|
+
} catch {
|
|
3603
|
+
return;
|
|
3604
|
+
}
|
|
3605
|
+
};
|
|
3606
|
+
while (true) {
|
|
3607
|
+
if (signal.aborted) return {
|
|
3608
|
+
endedBecause: "error",
|
|
3609
|
+
lastRunning: targetRunning,
|
|
3610
|
+
note: "本次调用被取消,等待中止;没有取消对方的回合,对方可能仍在作答。"
|
|
3611
|
+
};
|
|
3612
|
+
const elapsed = Date.now() - startedAt;
|
|
3613
|
+
if (elapsed >= input.waitMs) return {
|
|
3614
|
+
endedBecause: "timeout",
|
|
3615
|
+
lastRunning: targetRunning,
|
|
3616
|
+
note: `等待达到上限(${input.waitMs}ms),已返回当前区间内收集到的内容;没有取消对方的回合,对方可能仍在作答。`
|
|
3617
|
+
};
|
|
3618
|
+
await sleep(Math.min(ASK_POLL_INTERVAL_MS, Math.max(0, input.waitMs - elapsed)), signal);
|
|
3619
|
+
let row;
|
|
3620
|
+
try {
|
|
3621
|
+
row = (await controller.list({}, signal)).items.find((item) => item.sessionId === input.sessionId);
|
|
3622
|
+
} catch (error) {
|
|
3623
|
+
if (signal.aborted) return {
|
|
3624
|
+
endedBecause: "error",
|
|
3625
|
+
lastRunning: targetRunning,
|
|
3626
|
+
note: "本次调用被取消,等待中止;没有取消对方的回合。"
|
|
3627
|
+
};
|
|
3628
|
+
return {
|
|
3629
|
+
endedBecause: "error",
|
|
3630
|
+
lastRunning: targetRunning,
|
|
3631
|
+
note: `等待期间读取会话列表失败(${describeHostError(error)}),已返回当前区间内收集到的内容。`
|
|
3632
|
+
};
|
|
3633
|
+
}
|
|
3634
|
+
if (!row) continue;
|
|
3635
|
+
if (row.running) {
|
|
3636
|
+
observedBusy = true;
|
|
3637
|
+
targetRunning = true;
|
|
3638
|
+
continue;
|
|
3639
|
+
}
|
|
3640
|
+
if (observedBusy) return {
|
|
3641
|
+
endedBecause: "idle",
|
|
3642
|
+
lastRunning: false,
|
|
3643
|
+
note: "等待结束于该会话整体空闲(idle)。"
|
|
3644
|
+
};
|
|
3645
|
+
if (input.fromSeq !== void 0) {
|
|
3646
|
+
const now = await watermark();
|
|
3647
|
+
if (now !== void 0 && now > input.fromSeq) return {
|
|
3648
|
+
endedBecause: "idle",
|
|
3649
|
+
lastRunning: false,
|
|
3650
|
+
note: "等待结束:该会话已追加新内容且当前不忙(对方的回合发生在两次轮询之间,忙的状态未被采到)。"
|
|
3651
|
+
};
|
|
3652
|
+
}
|
|
3653
|
+
input.log(`waiting for ${input.sessionId} to start the delivered turn`);
|
|
3654
|
+
targetRunning = false;
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
/** Read the target's log back and collect what it said inside the interval. */
|
|
3658
|
+
async function collectTargetAnswer(controller, sessionId, fromSeq) {
|
|
3659
|
+
try {
|
|
3660
|
+
const resolved = await controller.resolveAgent(sessionId);
|
|
3661
|
+
if (!("agent" in resolved) || !resolved.agent) return {
|
|
3662
|
+
pieces: [],
|
|
3663
|
+
readError: describeHostError("error" in resolved ? resolved.error : void 0)
|
|
3664
|
+
};
|
|
3665
|
+
return { pieces: collectAnswer(agentEvents(resolved.agent), fromSeq).pieces };
|
|
3666
|
+
} catch (error) {
|
|
3667
|
+
return {
|
|
3668
|
+
pieces: [],
|
|
3669
|
+
readError: describeHostError(error)
|
|
3670
|
+
};
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
/**
|
|
3674
|
+
* Every pre-delivery guard, in one place. Pure: takes plain facts, returns a
|
|
3675
|
+
* rejection or nothing. Order matters — identity before ownership, ownership
|
|
3676
|
+
* before budget, budget before hop.
|
|
3677
|
+
* @returns the rejection, or undefined when the ask may proceed.
|
|
3678
|
+
*/
|
|
3679
|
+
function checkAskGuards(input) {
|
|
3680
|
+
const { guards, match } = input;
|
|
3681
|
+
if (match.kind === "missing") return {
|
|
3682
|
+
code: "target-not-found",
|
|
3683
|
+
message: "没有找到匹配的会话。请用会话列表里的标题或 sessionId 指定目标;不确认时先列出候选再让用户选择。"
|
|
3684
|
+
};
|
|
3685
|
+
if (match.kind === "ambiguous") return {
|
|
3686
|
+
code: "target-ambiguous",
|
|
3687
|
+
message: `有 ${match.candidates.length} 个会话都叫这个名字,不能猜。请把候选交给用户挑选(用 sessionId 指定即可精确命中)。`,
|
|
3688
|
+
candidates: match.candidates
|
|
3689
|
+
};
|
|
3690
|
+
const target = input.target ?? match.target;
|
|
3691
|
+
if (target.sessionId === guards.currentSessionId) return {
|
|
3692
|
+
code: "self-target",
|
|
3693
|
+
message: "目标是当前会话自己,拒绝自问自答。"
|
|
3694
|
+
};
|
|
3695
|
+
if (input.sessions.find((session) => session.sessionId === target.sessionId)?.origin === "subagent") return {
|
|
3696
|
+
code: "subagent-target",
|
|
3697
|
+
message: "目标会话是子代理(subagent)会话,跨会话投递被宿主硬性排除。请改用子代理通道(subagent / send_message)。"
|
|
3698
|
+
};
|
|
3699
|
+
const bytes = askContentBytes(input.question, input.context);
|
|
3700
|
+
if (bytes > 32e3) return {
|
|
3701
|
+
code: "content-too-large",
|
|
3702
|
+
message: `问题加背景共 ${bytes} 字节,超过上限 ${ASK_CONTENT_MAX_BYTES} 字节。请缩短问题,或把背景留在自己的会话里分次提问。`
|
|
3703
|
+
};
|
|
3704
|
+
if (guards.hop > 2) return {
|
|
3705
|
+
code: "hop-limit",
|
|
3706
|
+
message: `本次提问会形成第 ${guards.hop} 跳转发(上限 3 跳:0→2)。会话链式互问会绕成环,已拒绝;请由用户直接指定目标会话,或让当前会话自己作答。`
|
|
3707
|
+
};
|
|
3708
|
+
}
|
|
3709
|
+
/** Normalise and validate the model-supplied wait budget. */
|
|
3710
|
+
function normalizeWaitMs(value) {
|
|
3711
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return ASK_WAIT_DEFAULT_MS;
|
|
3712
|
+
return Math.min(Math.floor(value), ASK_WAIT_MAX_MS);
|
|
3713
|
+
}
|
|
3714
|
+
async function askSession(ctx, signal, input) {
|
|
3715
|
+
const controller = getSessionController(ctx);
|
|
3716
|
+
if (!controller) return {
|
|
3717
|
+
ok: false,
|
|
3718
|
+
error: "service-missing",
|
|
3719
|
+
message: "此功能需要较新的 DSH 宿主:当前宿主没有提供 sessionController 服务,无法向其他会话投递问题。"
|
|
3720
|
+
};
|
|
3721
|
+
const waitMs = normalizeWaitMs(input.waitMs);
|
|
3722
|
+
if (typeof input.waitMs === "number" && input.waitMs > 6e5) (input.log ?? (() => {}))(`ask_session waitMs 超出上限,已按 ${waitMs}ms 处理`);
|
|
3723
|
+
if (isWaiting(input.currentSessionId)) return toToolError({
|
|
3724
|
+
code: "already-waiting",
|
|
3725
|
+
message: "当前会话已经在等另一个会话的答复(同一时刻只允许等一个),请等这次返回后再发起新的提问。"
|
|
3726
|
+
});
|
|
3727
|
+
pendingWaits.add(input.currentSessionId);
|
|
3728
|
+
try {
|
|
3729
|
+
const sessions = [];
|
|
3730
|
+
let match = { kind: "missing" };
|
|
3731
|
+
try {
|
|
3732
|
+
const listed = await controller.list({}, signal);
|
|
3733
|
+
sessions.push(...listed.items);
|
|
3734
|
+
match = matchTargetSession(sessions, input.target ?? "");
|
|
3735
|
+
} catch (error) {
|
|
3736
|
+
return {
|
|
3737
|
+
ok: false,
|
|
3738
|
+
error: "service-missing",
|
|
3739
|
+
message: `读取会话列表失败:${describeHostError(error)}`
|
|
3740
|
+
};
|
|
3741
|
+
}
|
|
3742
|
+
return await runAsk(ctx, controller, signal, input, {
|
|
3743
|
+
sessions,
|
|
3744
|
+
match,
|
|
3745
|
+
log: input.log ?? (() => {})
|
|
3746
|
+
});
|
|
3747
|
+
} finally {
|
|
3748
|
+
pendingWaits.delete(input.currentSessionId);
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
/**
|
|
3752
|
+
* The ask itself, after the target list has been read and the wait latch is
|
|
3753
|
+
* held by the caller.
|
|
3754
|
+
*/
|
|
3755
|
+
async function runAsk(ctx, controller, signal, input, listed) {
|
|
3756
|
+
const waitMs = normalizeWaitMs(input.waitMs);
|
|
3757
|
+
const hop = nextAskHop(input.currentEvents);
|
|
3758
|
+
const rejectionBase = checkAskGuards({
|
|
3759
|
+
guards: {
|
|
3760
|
+
currentSessionId: input.currentSessionId,
|
|
3761
|
+
hop
|
|
3762
|
+
},
|
|
3763
|
+
match: listed.match,
|
|
3764
|
+
sessions: listed.sessions,
|
|
3765
|
+
question: input.question,
|
|
3766
|
+
context: input.context
|
|
3767
|
+
});
|
|
3768
|
+
if (rejectionBase) return toToolError(rejectionBase);
|
|
3769
|
+
const target = listed.match.kind === "unique" ? listed.match.target : {
|
|
3770
|
+
sessionId: "",
|
|
3771
|
+
name: ""
|
|
3772
|
+
};
|
|
3773
|
+
const targetRow = listed.sessions.find((session) => session.sessionId === target.sessionId);
|
|
3774
|
+
const askId = nextAskId();
|
|
3775
|
+
const source = {
|
|
3776
|
+
kind: "session-ask",
|
|
3777
|
+
form: "relay",
|
|
3778
|
+
askId,
|
|
3779
|
+
senderSessionId: input.currentSessionId,
|
|
3780
|
+
senderName: input.senderName,
|
|
3781
|
+
hop
|
|
3782
|
+
};
|
|
3783
|
+
const from = (/* @__PURE__ */ new Date()).toISOString();
|
|
3784
|
+
const delivery = await deliverAsk(controller, signal, {
|
|
3785
|
+
target,
|
|
3786
|
+
question: input.question,
|
|
3787
|
+
context: input.context,
|
|
3788
|
+
source,
|
|
3789
|
+
targetWasRunning: targetRow?.running === true
|
|
3790
|
+
});
|
|
3791
|
+
if (!delivery.ok) return {
|
|
3792
|
+
ok: false,
|
|
3793
|
+
error: delivery.code,
|
|
3794
|
+
message: delivery.message
|
|
3795
|
+
};
|
|
3796
|
+
input.onDelivered?.({
|
|
3797
|
+
askId,
|
|
3798
|
+
question: input.question,
|
|
3799
|
+
...input.context === void 0 ? {} : { context: input.context },
|
|
3800
|
+
senderName: input.senderName,
|
|
3801
|
+
targetName: target.name
|
|
3802
|
+
});
|
|
3803
|
+
const outcome = await waitForTargetIdle(controller, signal, {
|
|
3804
|
+
sessionId: target.sessionId,
|
|
3805
|
+
targetWasRunning: delivery.receipt.targetWasRunning,
|
|
3806
|
+
waitMs,
|
|
3807
|
+
log: listed.log,
|
|
3808
|
+
fromSeq: delivery.receipt.fromSeq
|
|
3809
|
+
});
|
|
3810
|
+
const collected = await collectTargetAnswer(controller, target.sessionId, delivery.receipt.fromSeq);
|
|
3811
|
+
const answer = joinAnswerPieces(collected.pieces);
|
|
3812
|
+
const to = outcome.endedBecause === "idle" ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
3813
|
+
const note = [
|
|
3814
|
+
"区间语义:从投递回执到对方会话下一次整体空闲,这两个时刻之间对方产出的文字都算在内——这是「这个区间里对方说的话」,不是逐条对应的回答。",
|
|
3815
|
+
delivery.receipt.note,
|
|
3816
|
+
outcome.note,
|
|
3817
|
+
delivery.receipt.targetWasRunning ? "投递时对方正在忙,问题已排队,会在它当前回合结束后进入新回合。" : "投递时对方不忙,宿主已唤醒它开一个新回合。",
|
|
3818
|
+
collected.readError === void 0 ? "" : `读取对方会话日志时出错,answer 可能不完整:${collected.readError}`
|
|
3819
|
+
].filter((line) => line.length > 0).join("\n");
|
|
3820
|
+
input.onSettled?.({
|
|
3821
|
+
askId,
|
|
3822
|
+
question: input.question,
|
|
3823
|
+
targetName: target.name,
|
|
3824
|
+
answer,
|
|
3825
|
+
note
|
|
3826
|
+
});
|
|
3827
|
+
return {
|
|
3828
|
+
ok: true,
|
|
3829
|
+
target: {
|
|
3830
|
+
sessionId: target.sessionId,
|
|
3831
|
+
name: target.name
|
|
3832
|
+
},
|
|
3833
|
+
delivered: delivery.receipt.delivered,
|
|
3834
|
+
askId,
|
|
3835
|
+
interval: {
|
|
3836
|
+
from,
|
|
3837
|
+
to,
|
|
3838
|
+
endedBecause: outcome.endedBecause
|
|
3839
|
+
},
|
|
3840
|
+
answer,
|
|
3841
|
+
note
|
|
3842
|
+
};
|
|
3843
|
+
}
|
|
3844
|
+
function toToolError(rejection) {
|
|
3845
|
+
return {
|
|
3846
|
+
ok: false,
|
|
3847
|
+
error: rejection.code,
|
|
3848
|
+
message: rejection.message,
|
|
3849
|
+
...rejection.candidates ? { candidates: rejection.candidates } : {}
|
|
3850
|
+
};
|
|
3851
|
+
}
|
|
3852
|
+
//#endregion
|
|
3853
|
+
//#region src/tools.ts
|
|
3854
|
+
function registerAdvisorTools(service, ctx) {
|
|
3855
|
+
return [
|
|
3856
|
+
defineTool({
|
|
3857
|
+
name: "ask_advisors",
|
|
3858
|
+
description: "Consult configured expert advisor models when the question is professional, long-tail world knowledge, high-risk, or the main model is uncertain. ALWAYS provide context with (a) a brief overview of the current project and (b) concrete details of the problem, including what has been tried and what went wrong. Also call immediately when the user @顾问群 or repeats the same question three times without resolution. Do NOT use for simple facts that web search can answer. Starting a new consultation runs the auto-deepen pipeline: up to maxRounds rounds of [driver deep-question → advisor A → advisor B (sees A) → advisor C (sees A+B) → …], closed by a driver-generated conclusion.",
|
|
3859
|
+
parameters: {
|
|
3860
|
+
question: {
|
|
3861
|
+
type: "string",
|
|
3862
|
+
description: "The question to ask the advisors. Required when starting a new consultation."
|
|
3863
|
+
},
|
|
3864
|
+
context: {
|
|
3865
|
+
type: "string",
|
|
3866
|
+
description: "Required background: (a) current project overview (domain/goal/stack/constraints) and (b) problem details (steps tried, expected vs actual, errors). Write \"该项背景未知\" for anything unknown; never leave it empty."
|
|
3867
|
+
},
|
|
3868
|
+
sessionId: {
|
|
3869
|
+
type: "string",
|
|
3870
|
+
description: "Existing consultation session id to continue."
|
|
3871
|
+
},
|
|
3872
|
+
followUp: {
|
|
3873
|
+
type: "string",
|
|
3874
|
+
description: "Follow-up question to send to the advisors in an existing session."
|
|
3875
|
+
},
|
|
3876
|
+
advisorIds: {
|
|
3877
|
+
type: "array",
|
|
3878
|
+
items: { type: "string" },
|
|
3879
|
+
description: "Optional advisor ids to include; defaults to all configured advisors (capped by maxAdvisorsPerCall)."
|
|
3880
|
+
},
|
|
3881
|
+
confidence: {
|
|
3882
|
+
type: "number",
|
|
3883
|
+
description: "Optional self-assessed confidence 0-1. Values below the configured threshold escalate even without domain keywords."
|
|
3884
|
+
},
|
|
3885
|
+
questionEn: {
|
|
3886
|
+
type: "string",
|
|
3887
|
+
description: "Optional self-contained English gist of the question plus key background. Used as the Jev pre-classification input only when the「Jev 英文判定」setting is enabled; the displayed question, session records, and duplicate detection always keep using question/context in Chinese. Omit it otherwise."
|
|
3888
|
+
}
|
|
3889
|
+
},
|
|
3890
|
+
output: {
|
|
3891
|
+
schema: {
|
|
3892
|
+
type: "object",
|
|
3893
|
+
additionalProperties: false,
|
|
3894
|
+
properties: {
|
|
3895
|
+
sessionId: {
|
|
3896
|
+
type: "string",
|
|
3897
|
+
required: true
|
|
3898
|
+
},
|
|
3899
|
+
advice: {
|
|
3900
|
+
type: "string",
|
|
3901
|
+
required: true
|
|
3902
|
+
},
|
|
3903
|
+
skipped: { type: "boolean" },
|
|
3904
|
+
reason: { type: "string" }
|
|
3905
|
+
}
|
|
3906
|
+
},
|
|
3907
|
+
render: (_args, value) => [{
|
|
3908
|
+
type: "text",
|
|
3909
|
+
text: value.advice
|
|
3910
|
+
}]
|
|
3911
|
+
},
|
|
3912
|
+
async execute(args, exec) {
|
|
3913
|
+
const cfg = service.getConfig();
|
|
3914
|
+
if (args.confidence !== void 0 && (args.confidence < 0 || args.confidence > 1)) return {
|
|
3915
|
+
sessionId: "",
|
|
3916
|
+
advice: "confidence 参数必须在 0-1 之间。",
|
|
3917
|
+
skipped: true,
|
|
3918
|
+
reason: "invalid-confidence"
|
|
3919
|
+
};
|
|
3920
|
+
if (!service.isEnabled()) return {
|
|
3921
|
+
sessionId: "",
|
|
3922
|
+
advice: "顾问群插件已禁用。请先调用 toggle_advisor_group 启用。",
|
|
3923
|
+
skipped: true,
|
|
3924
|
+
reason: "disabled"
|
|
3925
|
+
};
|
|
3926
|
+
if (cfg.advisors.length === 0) return {
|
|
3927
|
+
sessionId: "",
|
|
3928
|
+
advice: "顾问群尚未配置任何顾问。请先到 设置 → 插件 → 顾问群 添加顾问。",
|
|
3929
|
+
skipped: true,
|
|
3930
|
+
reason: "no-advisors"
|
|
3931
|
+
};
|
|
3932
|
+
const withoutModel = advisorsMissingModel(cfg);
|
|
3933
|
+
if (withoutModel.length > 0) return {
|
|
3934
|
+
sessionId: "",
|
|
3935
|
+
advice: `顾问群中有 ${withoutModel.length} 位顾问未配置模型(${withoutModel.map((a) => a.name).join("、")})。请先到 设置 → 插件 → 顾问群 补全模型后再发起,避免整轮调用失败。`,
|
|
3936
|
+
skipped: true,
|
|
3937
|
+
reason: "advisor-model-missing"
|
|
3938
|
+
};
|
|
3939
|
+
const sessionLog = exec.agent?.session;
|
|
3940
|
+
const questionText = typeof args.question === "string" ? args.question : "";
|
|
3941
|
+
const contextText = typeof args.context === "string" ? args.context : "";
|
|
3942
|
+
const mentionedAdvisorGroup = /@顾问群|@顧問群/.test(`${questionText}\n${contextText}`);
|
|
3943
|
+
const repeatCount = service.getRepeatPressure(sessionLog?.id)?.count ?? 0;
|
|
3944
|
+
const forcedByRepeat = repeatCount >= 3;
|
|
3945
|
+
let shadow;
|
|
3946
|
+
let shadowWritten = false;
|
|
3947
|
+
const recordShadow = (launched) => {
|
|
3948
|
+
if (!shadow || shadowWritten) return;
|
|
3949
|
+
shadowWritten = true;
|
|
3950
|
+
appendShadowSample({
|
|
3951
|
+
...shadow,
|
|
3952
|
+
launched
|
|
3953
|
+
});
|
|
3954
|
+
};
|
|
3955
|
+
if (args.sessionId) {
|
|
3956
|
+
if (!args.followUp) return {
|
|
3957
|
+
sessionId: args.sessionId,
|
|
3958
|
+
advice: "继续追问需要提供 followUp 参数。",
|
|
3959
|
+
skipped: true,
|
|
3960
|
+
reason: "missing-follow-up"
|
|
3961
|
+
};
|
|
3962
|
+
const session = service.getSession(args.sessionId);
|
|
3963
|
+
if (!session) return {
|
|
3964
|
+
sessionId: args.sessionId,
|
|
3965
|
+
advice: "顾问群会话不存在或已过期,请重新发起 ask_advisors。",
|
|
3966
|
+
skipped: true,
|
|
3967
|
+
reason: "session-not-found"
|
|
3968
|
+
};
|
|
3969
|
+
if (service.isSessionRunning(session.id)) return {
|
|
3970
|
+
sessionId: session.id,
|
|
3971
|
+
advice: "该顾问群会话正在运行中,请等本轮结束后再追问。",
|
|
3972
|
+
skipped: true,
|
|
3973
|
+
reason: "session-running"
|
|
3974
|
+
};
|
|
3975
|
+
if (service.hasReachedMaxRounds(session)) return {
|
|
3976
|
+
sessionId: session.id,
|
|
3977
|
+
advice: "本轮已是顾问群讨论的最后一轮,请综合现有顾问意见给出最终答复,不要再追问。",
|
|
3978
|
+
skipped: true,
|
|
3979
|
+
reason: "max-rounds-reached"
|
|
3980
|
+
};
|
|
3981
|
+
service.appendFollowUp(session.id, args.followUp, sessionLog);
|
|
3982
|
+
const summary = await service.runOneRoundAndSummarize(session, exec.signal, sessionLog, exec.agent);
|
|
3983
|
+
const canContinue = !service.hasReachedMaxRounds(session);
|
|
3984
|
+
return {
|
|
3985
|
+
sessionId: session.id,
|
|
3986
|
+
advice: formatConversation(session) + formatRiskNotes(summary) + (canContinue ? "\n\n(如需继续,可再次传入 sessionId 和 followUp 追问。)" : "\n\n(讨论轮数已达上限,请综合顾问意见给出最终答复。)"),
|
|
3987
|
+
skipped: false
|
|
3988
|
+
};
|
|
3989
|
+
}
|
|
3990
|
+
if (!args.question) return {
|
|
3991
|
+
sessionId: "",
|
|
3992
|
+
advice: "发起新讨论需要提供 question 参数。",
|
|
3993
|
+
skipped: true,
|
|
3994
|
+
reason: "missing-question"
|
|
3995
|
+
};
|
|
3996
|
+
if (cfg.trigger.requireClassifier && !mentionedAdvisorGroup && !forcedByRepeat) {
|
|
3997
|
+
let classification;
|
|
3998
|
+
let jevResult;
|
|
3999
|
+
let jevError;
|
|
4000
|
+
let jevLatencyMs;
|
|
4001
|
+
const jev = cfg.trigger.jev;
|
|
4002
|
+
if (jev?.enabled) {
|
|
4003
|
+
const jevStartedAt = Date.now();
|
|
4004
|
+
try {
|
|
4005
|
+
jevResult = await classifyWithJev(args.question, args.context, cfg.advisors, jev, exec.signal, typeof args.questionEn === "string" ? args.questionEn : void 0);
|
|
4006
|
+
classification = jevResult;
|
|
4007
|
+
} catch (error) {
|
|
4008
|
+
if (exec.signal.aborted) throw error;
|
|
4009
|
+
jevError = error instanceof Error ? error.message : String(error);
|
|
4010
|
+
console.warn("[dsh-advisor-group] Jev 分类失败,回退到本地分类器:", sessionLog?.id ?? "no-session", jevError);
|
|
4011
|
+
} finally {
|
|
4012
|
+
jevLatencyMs = Date.now() - jevStartedAt;
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
classification ??= classifyRequest(args.question, args.context, cfg.advisors, args.confidence, cfg.trigger.confidenceThreshold);
|
|
4016
|
+
shadow = {
|
|
4017
|
+
ts: Date.now(),
|
|
4018
|
+
question: args.question,
|
|
4019
|
+
...typeof args.confidence === "number" ? { confidence: args.confidence } : {},
|
|
4020
|
+
shouldEscalate: classification.shouldEscalate,
|
|
4021
|
+
reason: classification.reason,
|
|
4022
|
+
suggestWebSearch: classification.suggestWebSearch,
|
|
4023
|
+
provider: jevResult ? "jev" : "local",
|
|
4024
|
+
...jevError === void 0 ? {} : { jevError },
|
|
4025
|
+
...jevLatencyMs === void 0 ? {} : { jevLatencyMs },
|
|
4026
|
+
...repeatCount > 0 ? { repeatCount } : {},
|
|
4027
|
+
...jevResult?.rawAnswers ? { scores: jevResult.rawAnswers } : {},
|
|
4028
|
+
...jevResult?.model ? { model: jevResult.model } : {}
|
|
4029
|
+
};
|
|
4030
|
+
if (!classification.shouldEscalate) {
|
|
4031
|
+
recordShadow(false);
|
|
4032
|
+
if (classification.suggestWebSearch && cfg.trigger.allowWebFallback) return {
|
|
4033
|
+
sessionId: "",
|
|
4034
|
+
advice: `前置分类器建议不启动顾问群:${classification.reason}`,
|
|
4035
|
+
skipped: true,
|
|
4036
|
+
reason: "web-search-recommended"
|
|
4037
|
+
};
|
|
4038
|
+
return {
|
|
4039
|
+
sessionId: "",
|
|
4040
|
+
advice: `前置分类器建议不启动顾问群:${classification.reason}`,
|
|
4041
|
+
skipped: true,
|
|
4042
|
+
reason: "classifier-rejected"
|
|
4043
|
+
};
|
|
4044
|
+
}
|
|
4045
|
+
} else if (cfg.trigger.requireClassifier) shadow = {
|
|
4046
|
+
ts: Date.now(),
|
|
4047
|
+
question: args.question,
|
|
4048
|
+
shouldEscalate: true,
|
|
4049
|
+
reason: mentionedAdvisorGroup ? "用户点名 @顾问群,跳过前置分类器。" : `同一问题已重复 ${repeatCount} 次,强制升级跳过前置分类器。`,
|
|
4050
|
+
suggestWebSearch: false,
|
|
4051
|
+
provider: "bypass",
|
|
4052
|
+
bypass: mentionedAdvisorGroup ? "mention" : "repeat",
|
|
4053
|
+
repeatCount
|
|
4054
|
+
};
|
|
4055
|
+
if (args.advisorIds && args.advisorIds.length > 0) {
|
|
4056
|
+
const configuredIds = new Set(cfg.advisors.map((advisor) => advisor.id));
|
|
4057
|
+
if (args.advisorIds.filter((id) => configuredIds.has(id)).length === 0) {
|
|
4058
|
+
recordShadow(false);
|
|
4059
|
+
return {
|
|
4060
|
+
sessionId: "",
|
|
4061
|
+
advice: "未匹配到任何指定顾问,请检查 advisorIds 是否与设置中的顾问 id 一致。",
|
|
4062
|
+
skipped: true,
|
|
4063
|
+
reason: "no-matching-advisors"
|
|
4064
|
+
};
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
const costGuard = service.tryStartConsultation();
|
|
4068
|
+
if (!costGuard.ok) {
|
|
4069
|
+
recordShadow(false);
|
|
4070
|
+
return {
|
|
4071
|
+
sessionId: "",
|
|
4072
|
+
advice: costGuard.reason,
|
|
4073
|
+
skipped: true,
|
|
4074
|
+
reason: "daily-limit-reached"
|
|
4075
|
+
};
|
|
4076
|
+
}
|
|
4077
|
+
const session = service.createSession(args.question, args.context, args.advisorIds, sessionLog ? sessionLog.header.cwd : void 0, sessionLog ? sessionLog.id : void 0);
|
|
4078
|
+
if (sessionLog) appendAdvisorStart(sessionLog, session);
|
|
4079
|
+
recordShadow(true);
|
|
4080
|
+
const summary = await service.runAutoPipeline(session, exec.signal, sessionLog, exec.agent);
|
|
4081
|
+
return {
|
|
4082
|
+
sessionId: session.id,
|
|
4083
|
+
advice: formatConversation(session) + (summary.conclusion ? `\n\n【综合结论】\n${summary.conclusion}` : "") + formatRiskNotes(summary) + (service.hasReachedMaxRounds(session) ? "\n\n(已达设置的最大讨论轮数,请参考上述顾问意见与综合结论给出最终答复。)" : ""),
|
|
4084
|
+
skipped: false
|
|
4085
|
+
};
|
|
4086
|
+
}
|
|
4087
|
+
}),
|
|
4088
|
+
defineTool({
|
|
4089
|
+
name: "toggle_advisor_group",
|
|
4090
|
+
description: "Enable or disable the advisor group plugin at runtime. Omit enabled to toggle the current state.",
|
|
4091
|
+
parameters: { enabled: {
|
|
4092
|
+
type: "boolean",
|
|
4093
|
+
description: "Optional desired state."
|
|
4094
|
+
} },
|
|
4095
|
+
output: {
|
|
4096
|
+
schema: {
|
|
4097
|
+
type: "object",
|
|
4098
|
+
additionalProperties: false,
|
|
4099
|
+
properties: { enabled: {
|
|
4100
|
+
type: "boolean",
|
|
4101
|
+
required: true
|
|
4102
|
+
} }
|
|
4103
|
+
},
|
|
4104
|
+
render: (_args, value) => [{
|
|
4105
|
+
type: "text",
|
|
4106
|
+
text: `顾问群插件已${value.enabled ? "启用" : "禁用"}。`
|
|
4107
|
+
}]
|
|
4108
|
+
},
|
|
4109
|
+
async execute(args) {
|
|
4110
|
+
const next = typeof args.enabled === "boolean" ? args.enabled : !service.isEnabled();
|
|
4111
|
+
return { enabled: await service.toggleEnabled(next) };
|
|
4112
|
+
}
|
|
4113
|
+
}),
|
|
4114
|
+
defineTool({
|
|
4115
|
+
name: "ask_session",
|
|
4116
|
+
description: "Ask another EXISTING ordinary session (another window/tab the user opened) a question, wake it, and return what that session said. Use ONLY when the user explicitly asks to ask another session/window (「去问另一个会话」「问一下那个窗口」). It wakes the target session and consumes its quota. The returned text is NOT a one-to-one reply: it is everything the target said between the delivery receipt and its next whole-session idle, so never present it as \"the answer to my message\". When the target title is ambiguous you MUST return the candidates and let the user choose — never guess. Never use it for subagent sessions or for yourself.",
|
|
4117
|
+
parameters: {
|
|
4118
|
+
target: {
|
|
4119
|
+
type: "string",
|
|
4120
|
+
description: "The target session: its full sessionId, its exact display title, or a natural partial name — the workspace/directory name the user mentioned is enough (「极简遥控器」 matches sessions under I:\\极简遥控器\\…). One unique match is used directly; several matches return the candidates (running / most recently active first) for the user to choose — never guess. Pass the name the user said — do NOT search log files or directories for session ids.",
|
|
4121
|
+
required: true
|
|
4122
|
+
},
|
|
4123
|
+
question: {
|
|
4124
|
+
type: "string",
|
|
4125
|
+
description: "The question to hand over. It is permanently recorded in the target session log, so write it self-contained.",
|
|
4126
|
+
required: true
|
|
4127
|
+
},
|
|
4128
|
+
context: {
|
|
4129
|
+
type: "string",
|
|
4130
|
+
description: "Optional background the target needs. Default: none. Anything sent here stays in the target session log forever, so send only what is necessary."
|
|
4131
|
+
},
|
|
4132
|
+
waitMs: {
|
|
4133
|
+
type: "number",
|
|
4134
|
+
description: `How long to wait for the target to finish, in milliseconds (default ${ASK_WAIT_DEFAULT_MS}, max ${ASK_WAIT_MAX_MS}). On timeout the already-collected text is returned and the target is NOT interrupted.`
|
|
4135
|
+
}
|
|
4136
|
+
},
|
|
4137
|
+
output: {
|
|
4138
|
+
schema: {
|
|
4139
|
+
type: "object",
|
|
4140
|
+
additionalProperties: false,
|
|
4141
|
+
properties: {
|
|
4142
|
+
ok: {
|
|
4143
|
+
type: "boolean",
|
|
4144
|
+
required: true
|
|
4145
|
+
},
|
|
4146
|
+
target: {
|
|
4147
|
+
type: "object",
|
|
4148
|
+
additionalProperties: false,
|
|
4149
|
+
properties: {
|
|
4150
|
+
sessionId: {
|
|
4151
|
+
type: "string",
|
|
4152
|
+
required: true
|
|
4153
|
+
},
|
|
4154
|
+
name: {
|
|
4155
|
+
type: "string",
|
|
4156
|
+
required: true
|
|
4157
|
+
}
|
|
4158
|
+
}
|
|
4159
|
+
},
|
|
4160
|
+
delivered: {
|
|
4161
|
+
type: "string",
|
|
4162
|
+
enum: ["native", "fallback"]
|
|
4163
|
+
},
|
|
4164
|
+
askId: { type: "string" },
|
|
4165
|
+
interval: {
|
|
4166
|
+
type: "object",
|
|
4167
|
+
additionalProperties: false,
|
|
4168
|
+
properties: {
|
|
4169
|
+
from: {
|
|
4170
|
+
type: "string",
|
|
4171
|
+
required: true
|
|
4172
|
+
},
|
|
4173
|
+
to: { oneOf: [{ type: "string" }, { type: "null" }] },
|
|
4174
|
+
endedBecause: {
|
|
4175
|
+
type: "string",
|
|
4176
|
+
enum: [
|
|
4177
|
+
"idle",
|
|
4178
|
+
"timeout",
|
|
4179
|
+
"error"
|
|
4180
|
+
],
|
|
4181
|
+
required: true
|
|
4182
|
+
}
|
|
4183
|
+
},
|
|
4184
|
+
required: true
|
|
4185
|
+
},
|
|
4186
|
+
answer: {
|
|
4187
|
+
type: "string",
|
|
4188
|
+
required: true
|
|
4189
|
+
},
|
|
4190
|
+
note: {
|
|
4191
|
+
type: "string",
|
|
4192
|
+
required: true
|
|
4193
|
+
},
|
|
4194
|
+
error: { type: "string" },
|
|
4195
|
+
message: { type: "string" },
|
|
4196
|
+
candidates: {
|
|
4197
|
+
type: "array",
|
|
4198
|
+
items: {
|
|
4199
|
+
type: "object",
|
|
4200
|
+
additionalProperties: false,
|
|
4201
|
+
properties: {
|
|
4202
|
+
sessionId: {
|
|
4203
|
+
type: "string",
|
|
4204
|
+
required: true
|
|
4205
|
+
},
|
|
4206
|
+
name: {
|
|
4207
|
+
type: "string",
|
|
4208
|
+
required: true
|
|
4209
|
+
},
|
|
4210
|
+
hint: {
|
|
4211
|
+
type: "string",
|
|
4212
|
+
required: true
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
}
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
},
|
|
4219
|
+
render: (_args, value) => value.ok === true ? renderAskResult({
|
|
4220
|
+
answer: String(value.answer ?? ""),
|
|
4221
|
+
note: String(value.note ?? "")
|
|
4222
|
+
}) : [{
|
|
4223
|
+
type: "text",
|
|
4224
|
+
text: `【ask_session 未完成:${String(value.error ?? "unknown")}】\n${String(value.message ?? "")}${Array.isArray(value.candidates) && value.candidates.length > 0 ? `\n候选会话:\n${value.candidates.map((item) => `- ${String(item.name ?? "")} · ${String(item.sessionId ?? "")}`).join("\n")}` : ""}`
|
|
4225
|
+
}]
|
|
4226
|
+
},
|
|
4227
|
+
async execute(args, exec) {
|
|
4228
|
+
const controller = getSessionController(ctx);
|
|
4229
|
+
if (!controller) return {
|
|
4230
|
+
ok: false,
|
|
4231
|
+
error: "service-missing",
|
|
4232
|
+
message: "此功能需要较新的 DSH 宿主:当前宿主没有提供 sessionController 服务,无法向其他会话投递问题。",
|
|
4233
|
+
answer: "",
|
|
4234
|
+
note: "",
|
|
4235
|
+
interval: errorInterval()
|
|
4236
|
+
};
|
|
4237
|
+
const session = exec.agent?.session ?? getCurrentSessionFrom(exec);
|
|
4238
|
+
const sessionId = session?.id;
|
|
4239
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return {
|
|
4240
|
+
ok: false,
|
|
4241
|
+
error: "service-missing",
|
|
4242
|
+
message: "无法确定当前会话的身份(工具执行上下文没有暴露会话),已拒绝投递。",
|
|
4243
|
+
answer: "",
|
|
4244
|
+
note: "",
|
|
4245
|
+
interval: errorInterval()
|
|
4246
|
+
};
|
|
4247
|
+
const question = typeof args.question === "string" ? args.question : "";
|
|
4248
|
+
const context = typeof args.context === "string" ? args.context : void 0;
|
|
4249
|
+
let senderName = "未命名会话";
|
|
4250
|
+
try {
|
|
4251
|
+
const own = (await controller.list({}, exec.signal)).items.find((item) => item.sessionId === sessionId);
|
|
4252
|
+
if (own) senderName = sessionDisplayName(own);
|
|
4253
|
+
} catch {}
|
|
4254
|
+
const cardLog = session;
|
|
4255
|
+
const onDelivered = (info) => {
|
|
4256
|
+
if (!cardLog) return;
|
|
4257
|
+
try {
|
|
4258
|
+
appendAskStart(cardLog, info);
|
|
4259
|
+
} catch (error) {
|
|
4260
|
+
console.warn("[dsh-advisor-group] 跨会话卡片(开始)写入失败:", error);
|
|
4261
|
+
}
|
|
4262
|
+
};
|
|
4263
|
+
const onSettled = (info) => {
|
|
4264
|
+
if (!cardLog) return;
|
|
4265
|
+
try {
|
|
4266
|
+
appendAskAnswer(cardLog, info);
|
|
4267
|
+
} catch (error) {
|
|
4268
|
+
console.warn("[dsh-advisor-group] 跨会话卡片(结束)写入失败:", error);
|
|
4269
|
+
}
|
|
4270
|
+
};
|
|
4271
|
+
const value = await askSession(ctx, exec.signal, {
|
|
4272
|
+
onDelivered,
|
|
4273
|
+
onSettled,
|
|
4274
|
+
currentSessionId: sessionId,
|
|
4275
|
+
senderName,
|
|
4276
|
+
currentEvents: readCurrentEvents(session),
|
|
4277
|
+
target: typeof args.target === "string" ? args.target : "",
|
|
4278
|
+
question,
|
|
4279
|
+
context,
|
|
4280
|
+
waitMs: args.waitMs,
|
|
4281
|
+
log: (message) => console.log(`[dsh-advisor-group] ask_session: ${message}`)
|
|
4282
|
+
});
|
|
4283
|
+
if (value.ok !== true) return {
|
|
4284
|
+
ok: false,
|
|
4285
|
+
error: String(value.error ?? "delivery-failed"),
|
|
4286
|
+
message: String(value.message ?? ""),
|
|
4287
|
+
...Array.isArray(value.candidates) ? { candidates: value.candidates } : {},
|
|
4288
|
+
answer: "",
|
|
4289
|
+
note: "",
|
|
4290
|
+
interval: errorInterval()
|
|
4291
|
+
};
|
|
4292
|
+
return {
|
|
4293
|
+
ok: true,
|
|
4294
|
+
target: value.target,
|
|
4295
|
+
delivered: value.delivered,
|
|
4296
|
+
askId: String(value.askId ?? ""),
|
|
4297
|
+
interval: value.interval,
|
|
4298
|
+
answer: String(value.answer ?? ""),
|
|
4299
|
+
note: String(value.note ?? "")
|
|
4300
|
+
};
|
|
4301
|
+
}
|
|
4302
|
+
})
|
|
4303
|
+
];
|
|
4304
|
+
}
|
|
4305
|
+
/**
|
|
4306
|
+
* Current session id from the tool execution context. `exec.agent.session` is
|
|
4307
|
+
* the Agent on whose behalf the call runs (same access `ask_advisors` uses for
|
|
4308
|
+
* `exec.agent?.session`); the fallback covers hosts that only expose a raw
|
|
4309
|
+
* session object on the execution input.
|
|
4310
|
+
*/
|
|
4311
|
+
function getCurrentSessionFrom(exec) {
|
|
4312
|
+
return exec?.session;
|
|
4313
|
+
}
|
|
4314
|
+
/** Read this session's own log for the inbound-source / hop guard; empty on failure. */
|
|
4315
|
+
function readCurrentEvents(session) {
|
|
4316
|
+
try {
|
|
4317
|
+
return session?.snapshotEvents() ?? [];
|
|
4318
|
+
} catch {
|
|
4319
|
+
return [];
|
|
4320
|
+
}
|
|
4321
|
+
}
|
|
4322
|
+
/** Empty interval carried on every not-ok result (no reply window ever opened). */
|
|
4323
|
+
function errorInterval() {
|
|
4324
|
+
return {
|
|
4325
|
+
from: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4326
|
+
endedBecause: "error"
|
|
4327
|
+
};
|
|
4328
|
+
}
|
|
4329
|
+
function formatRiskNotes(summary) {
|
|
4330
|
+
return summary.riskNotes.length > 0 ? `\n\n【风险提示】\n${summary.riskNotes.join("\n")}` : "";
|
|
4331
|
+
}
|
|
4332
|
+
function formatConversation(session) {
|
|
4333
|
+
const lines = [`顾问群对话(session: ${session.id})`, ""];
|
|
4334
|
+
for (const message of session.messages) {
|
|
4335
|
+
if (message.role === "system") continue;
|
|
4336
|
+
if (message.role === "main") lines.push(`主模型:${message.content}`);
|
|
4337
|
+
else if (message.role === "advisor") {
|
|
4338
|
+
const truncatedNote = message.truncated === void 0 ? "" : `(顾问输出在流式过程中被截断:${message.truncated.reason === "timeout" ? "超时" : "网络中断"},正文可能不完整)\n`;
|
|
4339
|
+
const toolNote = message.toolSteps && message.toolSteps.length > 0 ? `${message.toolSteps.map((step) => step.kind === "call" ? ` ⛭ 调用 ${step.name} · ${step.text}` : ` ↳ ${step.text}`).join("\n")}\n` : "";
|
|
4340
|
+
const body = message.content && message.content.trim() ? truncatedNote + toolNote + message.content : truncatedNote + toolNote + (message.thinking && message.thinking.trim() ? `(思维链)\n${message.thinking}` : "(顾问未返回正文)");
|
|
4341
|
+
lines.push(`【${message.advisorName ?? message.advisorId ?? "顾问"}】:${body}`);
|
|
4342
|
+
}
|
|
4343
|
+
lines.push("");
|
|
4344
|
+
}
|
|
4345
|
+
const stopNote = session.status === "cancelled" && session.stopReason ? `(注意:本次咨询被中断(${STOP_REASON_TEXT[session.stopReason] ?? session.stopReason});可在对话卡片点击「▶ 继续聊天」从断点续跑。)` : "";
|
|
4346
|
+
lines.push(stopNote);
|
|
4347
|
+
lines.push("(以上是顾问模型与主模型的对话记录,主模型可直接基于其中内容继续思考或追问。)");
|
|
4348
|
+
return lines.join("\n");
|
|
4349
|
+
}
|
|
4350
|
+
const STOP_REASON_TEXT = {
|
|
4351
|
+
"user-stop": "用户主动停止",
|
|
4352
|
+
"exec-cancel": "宿主取消",
|
|
4353
|
+
"abort-error": "流异常中断",
|
|
4354
|
+
timeout: "顾问响应超时",
|
|
4355
|
+
network: "网络中断"
|
|
4356
|
+
};
|
|
4357
|
+
//#endregion
|
|
4358
|
+
//#region src/prompt-guard.ts
|
|
4359
|
+
const REPLACEMENT = "�";
|
|
4360
|
+
const BOM = "";
|
|
4361
|
+
/**
|
|
4362
|
+
* Unambiguity check: same line count, and every character either equals the
|
|
4363
|
+
* template or is a replacement char (the byte-loss corruption signature).
|
|
4364
|
+
*/
|
|
4365
|
+
function matchesTemplateWithReplacement(storedLines, templateLines) {
|
|
4366
|
+
for (let i = 0; i < storedLines.length; i++) {
|
|
4367
|
+
const stored = storedLines[i];
|
|
4368
|
+
const template = templateLines[i];
|
|
4369
|
+
if (stored === template) continue;
|
|
4370
|
+
if (stored.length !== template.length) return false;
|
|
4371
|
+
for (let j = 0; j < stored.length; j++) if (stored[j] !== template[j] && stored[j] !== REPLACEMENT) return false;
|
|
4372
|
+
}
|
|
4373
|
+
return true;
|
|
4374
|
+
}
|
|
4375
|
+
/**
|
|
4376
|
+
* Advisor system-prompt corruption guard (P1, 2026-09-27).
|
|
4377
|
+
* Fast path is free: prompts without replacement chars pass through
|
|
4378
|
+
* untouched (a customized prompt is legitimate and must never be overwritten).
|
|
4379
|
+
* A leading BOM alone is stripped (action 'stripped', no template involved).
|
|
4380
|
+
* Only on a replacement char do we compare against the built-in template:
|
|
4381
|
+
* unambiguous (line count + per-character match) -> heal to the template;
|
|
4382
|
+
* anything else keeps the stored value and reports it for manual handling.
|
|
4383
|
+
*/
|
|
4384
|
+
function healAdvisorSystemPrompt(stored, template = DEFAULT_ADVISOR_PROMPT) {
|
|
4385
|
+
let value = stored;
|
|
4386
|
+
let hadBom = false;
|
|
4387
|
+
if (value.startsWith(BOM)) {
|
|
4388
|
+
value = value.slice(1);
|
|
4389
|
+
hadBom = true;
|
|
4390
|
+
}
|
|
4391
|
+
const replacements = (value.match(/\uFFFD/g) ?? []).length;
|
|
4392
|
+
if (replacements === 0) {
|
|
4393
|
+
if (hadBom) return {
|
|
4394
|
+
action: "stripped",
|
|
4395
|
+
value,
|
|
4396
|
+
reason: "开头含 BOM,已剥除"
|
|
4397
|
+
};
|
|
4398
|
+
return {
|
|
4399
|
+
action: "ok",
|
|
4400
|
+
value: stored
|
|
4401
|
+
};
|
|
4402
|
+
}
|
|
4403
|
+
const storedLines = value.split("\n");
|
|
4404
|
+
const templateLines = template.split("\n");
|
|
4405
|
+
if (storedLines.length !== templateLines.length) return {
|
|
4406
|
+
action: "needs-manual",
|
|
4407
|
+
value: stored,
|
|
4408
|
+
reason: `含 ${replacements} 个替换符,行数 ${storedLines.length} 与模板 ${templateLines.length} 不一致,无法无歧义回填`
|
|
4409
|
+
};
|
|
4410
|
+
if (!matchesTemplateWithReplacement(storedLines, templateLines)) return {
|
|
4411
|
+
action: "needs-manual",
|
|
4412
|
+
value: stored,
|
|
4413
|
+
reason: `含 ${replacements} 个替换符,但与模板逐位比对存在不一致(可能含自定义内容),未回填`
|
|
4414
|
+
};
|
|
4415
|
+
return {
|
|
4416
|
+
action: "healed",
|
|
4417
|
+
value: template,
|
|
4418
|
+
reason: `含 ${replacements} 个替换符${hadBom ? ",另开头含 BOM" : ""},行数与逐位比对均与模板一致,已回填内置模板`
|
|
4419
|
+
};
|
|
4420
|
+
}
|
|
4421
|
+
/**
|
|
4422
|
+
* Load-time guard over every advisor prompt. Returns the original config
|
|
4423
|
+
* object untouched when nothing was healed, so callers can rely on identity
|
|
4424
|
+
* checks to detect "no change".
|
|
4425
|
+
*/
|
|
4426
|
+
function guardAdvisorPrompts(config) {
|
|
4427
|
+
const findings = [];
|
|
4428
|
+
let healedAny = false;
|
|
4429
|
+
const advisors = config.advisors.map((advisor) => {
|
|
4430
|
+
const result = healAdvisorSystemPrompt(advisor.systemPrompt);
|
|
4431
|
+
if (result.action === "ok") return advisor;
|
|
4432
|
+
findings.push({
|
|
4433
|
+
advisorId: advisor.id,
|
|
4434
|
+
action: result.action,
|
|
4435
|
+
reason: result.reason ?? ""
|
|
4436
|
+
});
|
|
4437
|
+
if (result.action === "healed" || result.action === "stripped") {
|
|
4438
|
+
healedAny = true;
|
|
4439
|
+
return {
|
|
4440
|
+
...advisor,
|
|
4441
|
+
systemPrompt: result.value
|
|
4442
|
+
};
|
|
4443
|
+
}
|
|
4444
|
+
return advisor;
|
|
4445
|
+
});
|
|
4446
|
+
return {
|
|
4447
|
+
config: healedAny ? {
|
|
4448
|
+
...config,
|
|
4449
|
+
advisors
|
|
4450
|
+
} : config,
|
|
4451
|
+
findings
|
|
4452
|
+
};
|
|
4453
|
+
}
|
|
4454
|
+
/**
|
|
4455
|
+
* Shared logger for both call sites: one line per finding, stating the action
|
|
4456
|
+
* actually taken (healed vs left untouched) plus the concrete reason.
|
|
4457
|
+
* Every line carries an ISO-8601 timestamp so the host console redirect
|
|
4458
|
+
* (which does not timestamp lines itself) stays forensically usable.
|
|
4459
|
+
*/
|
|
4460
|
+
function reportPromptGuardFindings(findings) {
|
|
4461
|
+
const stamp = () => `[${(/* @__PURE__ */ new Date()).toISOString()}]`;
|
|
4462
|
+
for (const finding of findings) if (finding.action === "healed") console.warn(`${stamp()} [dsh-advisor-group] 顾问提示词损坏,已按内置模板回填:${finding.advisorId}(${finding.reason})`);
|
|
4463
|
+
else if (finding.action === "stripped") console.warn(`${stamp()} [dsh-advisor-group] 顾问提示词开头含 BOM,已剥除、正文未改动:${finding.advisorId}(${finding.reason})`);
|
|
4464
|
+
else console.warn(`${stamp()} [dsh-advisor-group] 顾问提示词损坏且无法无歧义回填,未改动、需人工处理:${finding.advisorId}(${finding.reason})`);
|
|
4465
|
+
}
|
|
4466
|
+
//#endregion
|
|
4467
|
+
//#region src/settings-api.ts
|
|
4468
|
+
function sendJson(res, status, body) {
|
|
4469
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
4470
|
+
res.end(JSON.stringify(body));
|
|
4471
|
+
}
|
|
4472
|
+
function normalizeJevConfig(input) {
|
|
4473
|
+
const apiKey = typeof input?.apiKey === "string" ? input.apiKey : input?.apiKey && typeof input.apiKey === "object" && "value" in input.apiKey ? String(input.apiKey.value ?? "") : void 0;
|
|
4474
|
+
return {
|
|
4475
|
+
enabled: input?.enabled ?? false,
|
|
4476
|
+
provider: input?.provider ?? "typesafe",
|
|
4477
|
+
model: input?.model ?? "jev-latest",
|
|
4478
|
+
...input?.baseURL ? { baseURL: input.baseURL } : {},
|
|
4479
|
+
...apiKey ? { apiKey } : {},
|
|
4480
|
+
...input?.apiKeyEnv ? { apiKeyEnv: input.apiKeyEnv } : {},
|
|
4481
|
+
timeoutMs: input?.timeoutMs ?? 1e4,
|
|
4482
|
+
confidenceThreshold: input?.confidenceThreshold ?? .6,
|
|
4483
|
+
useEnglishState: input?.useEnglishState ?? false,
|
|
4484
|
+
...typeof input?.highRiskThreshold === "number" ? { highRiskThreshold: input.highRiskThreshold } : {}
|
|
4485
|
+
};
|
|
4486
|
+
}
|
|
4487
|
+
/** Mask a stored API key so it never leaves the host in plain text. */
|
|
4488
|
+
function maskApiKey(apiKey) {
|
|
4489
|
+
if (!apiKey) return apiKey;
|
|
4490
|
+
if (apiKey.length <= 4) return "*".repeat(apiKey.length);
|
|
4491
|
+
return `${"*".repeat(apiKey.length - 4)}${apiKey.slice(-4)}`;
|
|
4492
|
+
}
|
|
4493
|
+
/**
|
|
4494
|
+
* Whether an inbound apiKey is a mask of the previously stored key.
|
|
4495
|
+
*
|
|
4496
|
+
* Accepts both the current length-preserving mask and the legacy 8-character
|
|
4497
|
+
* `****<last4>` mask so an unrefreshed settings page does not overwrite the
|
|
4498
|
+
* real key with a mask string after upgrading.
|
|
4499
|
+
*/
|
|
4500
|
+
function isMaskedApiKey(incoming, previous) {
|
|
4501
|
+
if (!incoming || !previous) return false;
|
|
4502
|
+
if (incoming === maskApiKey(previous)) return true;
|
|
4503
|
+
if (previous.length <= 4) return incoming === "*".repeat(previous.length);
|
|
4504
|
+
return incoming === `****${previous.slice(-4)}`;
|
|
4505
|
+
}
|
|
4506
|
+
/**
|
|
4507
|
+
* Return a copy of the config safe to send to the browser.
|
|
4508
|
+
*
|
|
4509
|
+
* SecretField convergence: no key material (not even a mask) leaves
|
|
4510
|
+
* the host. `apiKey` is returned empty and per-provider presence facts
|
|
4511
|
+
* (`configured` + `last4`) ride in `apiKeyMetaByProvider`, so the card can
|
|
4512
|
+
* render the official "已配置/未配置" badge and publish new/cleared keys
|
|
4513
|
+
* without ever round-tripping a mask.
|
|
4514
|
+
*/
|
|
4515
|
+
function sanitizeConfig(config) {
|
|
4516
|
+
const jev = normalizeJevConfig(config.trigger.jev);
|
|
4517
|
+
return {
|
|
4518
|
+
...config,
|
|
4519
|
+
trigger: {
|
|
4520
|
+
...config.trigger,
|
|
4521
|
+
jev: {
|
|
4522
|
+
...jev,
|
|
4523
|
+
apiKey: "",
|
|
4524
|
+
...jev?.apiKey ? { apiKeyMeta: {
|
|
4525
|
+
configured: true,
|
|
4526
|
+
last4: jev.apiKey.slice(-4)
|
|
4527
|
+
} } : {}
|
|
4528
|
+
}
|
|
4529
|
+
},
|
|
4530
|
+
advisors: config.advisors.map((advisor) => {
|
|
4531
|
+
const { apiKeysByProvider: _apiKeysByProvider, clearApiKey: _clearApiKey, apiKeyMetaByProvider: _meta, ...rest } = advisor;
|
|
4532
|
+
const meta = {};
|
|
4533
|
+
for (const [provider, key] of Object.entries(advisor.apiKeysByProvider ?? {})) if (key) meta[provider] = {
|
|
4534
|
+
configured: true,
|
|
4535
|
+
last4: key.slice(-4)
|
|
4536
|
+
};
|
|
4537
|
+
if (advisor.apiKey && advisor.provider) meta[advisor.provider] = {
|
|
4538
|
+
configured: true,
|
|
4539
|
+
last4: advisor.apiKey.slice(-4)
|
|
4540
|
+
};
|
|
2007
4541
|
return {
|
|
2008
4542
|
...rest,
|
|
2009
4543
|
apiKey: "",
|
|
@@ -2032,8 +4566,19 @@ function hasSameCredentialScope(a, b) {
|
|
|
2032
4566
|
*/
|
|
2033
4567
|
function reconcileApiKeys(incoming, previous) {
|
|
2034
4568
|
const previousById = new Map(previous.advisors.map((advisor) => [advisor.id, advisor]));
|
|
4569
|
+
const incomingJev = normalizeJevConfig(incoming.trigger.jev);
|
|
4570
|
+
const previousJev = normalizeJevConfig(previous.trigger.jev);
|
|
4571
|
+
const jevApiKey = incomingJev?.apiKey || previousJev?.apiKey;
|
|
2035
4572
|
return {
|
|
2036
4573
|
...incoming,
|
|
4574
|
+
trigger: {
|
|
4575
|
+
...incoming.trigger,
|
|
4576
|
+
jev: {
|
|
4577
|
+
...incomingJev,
|
|
4578
|
+
...jevApiKey ? { apiKey: jevApiKey } : {},
|
|
4579
|
+
apiKeyMeta: void 0
|
|
4580
|
+
}
|
|
4581
|
+
},
|
|
2037
4582
|
advisors: incoming.advisors.map((advisor) => {
|
|
2038
4583
|
const { clearApiKey, ...advisorRest } = advisor;
|
|
2039
4584
|
const prev = previousById.get(advisor.id);
|
|
@@ -2101,7 +4646,14 @@ async function parseLookupParams(req) {
|
|
|
2101
4646
|
return params;
|
|
2102
4647
|
}
|
|
2103
4648
|
const ALLOWED_API_KEY_ENVS = new Set(Object.values(PROVIDER_PRESETS).map((preset) => preset.apiKeyEnv));
|
|
4649
|
+
ALLOWED_API_KEY_ENVS.add("TYPESAFE_API_KEY");
|
|
4650
|
+
ALLOWED_API_KEY_ENVS.add("OPENROUTER_API_KEY");
|
|
2104
4651
|
function validateSecurity(input) {
|
|
4652
|
+
if (input.trigger.jev?.baseURL) {
|
|
4653
|
+
const url = input.trigger.jev.baseURL.trim();
|
|
4654
|
+
if (!(url.startsWith("https://") || url.startsWith("http://127.0.0.1") || url.startsWith("http://localhost"))) return "Jev API 地址仅允许 https:// 或本机 http://127.0.0.1 / http://localhost";
|
|
4655
|
+
}
|
|
4656
|
+
if (input.trigger.jev?.apiKeyEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(input.trigger.jev.apiKeyEnv.trim())) return `Jev API Key 环境变量名无效:${input.trigger.jev.apiKeyEnv}`;
|
|
2105
4657
|
const ids = /* @__PURE__ */ new Set();
|
|
2106
4658
|
for (const advisor of input.advisors) {
|
|
2107
4659
|
if (ids.has(advisor.id)) return `顾问 id 重复:${advisor.id}`;
|
|
@@ -2293,10 +4845,21 @@ async function handleModelsRequest(ctx, service, req, res) {
|
|
|
2293
4845
|
} catch (error) {
|
|
2294
4846
|
sendJson(res, 400, {
|
|
2295
4847
|
ok: false,
|
|
2296
|
-
error:
|
|
4848
|
+
error: describeConnectionError(error)
|
|
2297
4849
|
});
|
|
2298
4850
|
}
|
|
2299
4851
|
}
|
|
4852
|
+
/** 网络/HTTP 类失败的设置页可读文案;原始信息保留在括号里。 */
|
|
4853
|
+
function describeConnectionError(error) {
|
|
4854
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4855
|
+
if (/HTTP (401|403)\b/i.test(message)) return `密钥无效或没有访问权限(${message})`;
|
|
4856
|
+
if (/HTTP 404\b/i.test(message)) return `地址不存在,检查 baseURL 是否写对(${message})`;
|
|
4857
|
+
if (/ENOTFOUND|EAI_AGAIN/i.test(message)) return `找不到这个地址,检查域名拼写与网络(${message})`;
|
|
4858
|
+
if (/ECONNREFUSED/i.test(message)) return `对方拒绝了连接,检查地址与端口(${message})`;
|
|
4859
|
+
if (/ETIMEDOUT|\btimeout\b|aborted/i.test(message)) return `连接超时(${message})`;
|
|
4860
|
+
if (/fetch failed/i.test(message)) return `网络连接失败,检查网络后重试(${message})`;
|
|
4861
|
+
return `连接失败(${message})`;
|
|
4862
|
+
}
|
|
2300
4863
|
async function handleTestConnectionRequest(ctx, service, req, res) {
|
|
2301
4864
|
const params = await parseLookupParams(req);
|
|
2302
4865
|
const provider = params.provider ?? "";
|
|
@@ -2373,7 +4936,7 @@ async function handleTestConnectionRequest(ctx, service, req, res) {
|
|
|
2373
4936
|
} catch (error) {
|
|
2374
4937
|
sendJson(res, 400, {
|
|
2375
4938
|
ok: false,
|
|
2376
|
-
error:
|
|
4939
|
+
error: describeConnectionError(error)
|
|
2377
4940
|
});
|
|
2378
4941
|
}
|
|
2379
4942
|
}
|
|
@@ -2407,12 +4970,95 @@ function handleStreamRequest(req, res) {
|
|
|
2407
4970
|
subscribe(sessionId, res, lastEventId, bootId);
|
|
2408
4971
|
});
|
|
2409
4972
|
}
|
|
4973
|
+
/** First value of a possibly repeated header field. */
|
|
4974
|
+
function headerValue(value) {
|
|
4975
|
+
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
|
4976
|
+
}
|
|
4977
|
+
/** Origin of an http(s) URL; `''` for absent, opaque (`null`) or non-web values. */
|
|
4978
|
+
function webOrigin(value) {
|
|
4979
|
+
if (value === "" || value === "null") return "";
|
|
4980
|
+
try {
|
|
4981
|
+
const parsed = new URL(value);
|
|
4982
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : "";
|
|
4983
|
+
} catch {
|
|
4984
|
+
return "";
|
|
4985
|
+
}
|
|
4986
|
+
}
|
|
4987
|
+
/**
|
|
4988
|
+
* True when the handshake request may be handed the token. Only a *provably
|
|
4989
|
+
* foreign web page* is refused: an `Origin`/`Referer` that parses as http(s) on
|
|
4990
|
+
* another host. Same-origin pages pass, and so do callers carrying no web
|
|
4991
|
+
* provenance at all — the desktop shell, which forwards the page's request
|
|
4992
|
+
* without those headers, and any local program, which could forge them anyway.
|
|
4993
|
+
*
|
|
4994
|
+
* What actually keeps the token from a foreign page is not this check but the
|
|
4995
|
+
* absent CORS header: a cross-origin fetch cannot read a response body without
|
|
4996
|
+
* `Access-Control-Allow-Origin`, and this route never sends one. The check only
|
|
4997
|
+
* decides whether a foreign page is answered at all.
|
|
4998
|
+
*/
|
|
4999
|
+
function mayReadBootToken(req) {
|
|
5000
|
+
const host = headerValue(req.headers.host);
|
|
5001
|
+
const fromForeignPage = (value) => {
|
|
5002
|
+
const origin = webOrigin(value);
|
|
5003
|
+
if (origin === "" || host === "") return false;
|
|
5004
|
+
return origin !== `http://${host}` && origin !== `https://${host}`;
|
|
5005
|
+
};
|
|
5006
|
+
if (fromForeignPage(headerValue(req.headers.origin))) return false;
|
|
5007
|
+
if (fromForeignPage(headerValue(req.headers.referer))) return false;
|
|
5008
|
+
return true;
|
|
5009
|
+
}
|
|
5010
|
+
/** Set once the process has logged an accepted handshake (refusals always log). */
|
|
5011
|
+
let bootTokenAcceptedLogged = false;
|
|
5012
|
+
/**
|
|
5013
|
+
* Records one handshake attempt in
|
|
5014
|
+
* `$DSH_HOME/storages/advisor-group/boot-token.log`. Whether the desktop shell's
|
|
5015
|
+
* request even reaches this route is otherwise invisible from outside the app,
|
|
5016
|
+
* so each decision leaves a line; the file keeps only its most recent lines.
|
|
5017
|
+
* Query strings are dropped and the referer is reduced to its origin, so no
|
|
5018
|
+
* token can land in the log. A write failure never breaks the handshake.
|
|
5019
|
+
*/
|
|
5020
|
+
function recordBootTokenAttempt(req, allowed) {
|
|
5021
|
+
if (allowed && bootTokenAcceptedLogged) return;
|
|
5022
|
+
bootTokenAcceptedLogged = true;
|
|
5023
|
+
try {
|
|
5024
|
+
const dshHome = process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
5025
|
+
const target = join(dshHome, "storages", "advisor-group", "boot-token.log");
|
|
5026
|
+
const line = `${[
|
|
5027
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
5028
|
+
allowed ? "allow" : "deny",
|
|
5029
|
+
`site=${headerValue(req.headers["sec-fetch-site"]) || "-"}`,
|
|
5030
|
+
`origin=${headerValue(req.headers.origin) || "-"}`,
|
|
5031
|
+
`referer=${webOrigin(headerValue(req.headers.referer)) || "-"}`,
|
|
5032
|
+
`host=${headerValue(req.headers.host) || "-"}`,
|
|
5033
|
+
`ua=${headerValue(req.headers["user-agent"]).slice(0, 48) || "-"}`
|
|
5034
|
+
].join(" ")}\n`;
|
|
5035
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
5036
|
+
appendFileSync(target, line);
|
|
5037
|
+
if (statSync(target).size > 8192) writeFileSync(target, readFileSync(target, "utf8").split("\n").slice(-20).join("\n"));
|
|
5038
|
+
} catch {}
|
|
5039
|
+
}
|
|
2410
5040
|
function handleConfigRequest(ctx, scopedCtx, service, authToken) {
|
|
2411
|
-
const namespace = "advisor-group";
|
|
5041
|
+
const namespace = "dsh-advisor-group";
|
|
2412
5042
|
return async (req, res) => {
|
|
2413
5043
|
const rawUrl = req.url ?? "";
|
|
2414
5044
|
const url = new URL(rawUrl, "http://localhost");
|
|
2415
5045
|
const pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
5046
|
+
if (req.method === "GET" && (pathname === "/advisor-group/boot-token" || pathname === "/advisor-group/boot-token/")) {
|
|
5047
|
+
const allowed = mayReadBootToken(req);
|
|
5048
|
+
recordBootTokenAttempt(req, allowed);
|
|
5049
|
+
if (!allowed) {
|
|
5050
|
+
sendJson(res, 403, {
|
|
5051
|
+
ok: false,
|
|
5052
|
+
error: "forbidden"
|
|
5053
|
+
});
|
|
5054
|
+
return;
|
|
5055
|
+
}
|
|
5056
|
+
sendJson(res, 200, {
|
|
5057
|
+
ok: true,
|
|
5058
|
+
token: authToken
|
|
5059
|
+
});
|
|
5060
|
+
return;
|
|
5061
|
+
}
|
|
2416
5062
|
const token = String(req.headers["x-advisor-group-token"] ?? url.searchParams.get("token") ?? "");
|
|
2417
5063
|
if (authToken && token !== authToken) {
|
|
2418
5064
|
sendJson(res, 401, {
|
|
@@ -2437,6 +5083,55 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
|
|
|
2437
5083
|
await sendProviders(ctx, res);
|
|
2438
5084
|
return;
|
|
2439
5085
|
}
|
|
5086
|
+
if (req.method === "POST" && (pathname === "/advisor-group/stop" || pathname === "/advisor-group/stop/")) {
|
|
5087
|
+
try {
|
|
5088
|
+
const raw = await readBody(req);
|
|
5089
|
+
const parsed = JSON.parse(raw);
|
|
5090
|
+
if (typeof parsed.sessionId !== "string" || !parsed.sessionId) {
|
|
5091
|
+
sendJson(res, 400, {
|
|
5092
|
+
ok: false,
|
|
5093
|
+
error: "缺少 sessionId"
|
|
5094
|
+
});
|
|
5095
|
+
return;
|
|
5096
|
+
}
|
|
5097
|
+
sendJson(res, 200, {
|
|
5098
|
+
ok: true,
|
|
5099
|
+
stopped: service.stopConsultation(parsed.sessionId)
|
|
5100
|
+
});
|
|
5101
|
+
} catch (error) {
|
|
5102
|
+
console.warn("[dsh-advisor-group] stop 请求处理失败:", error instanceof Error ? error.message : String(error));
|
|
5103
|
+
sendJson(res, 400, {
|
|
5104
|
+
ok: false,
|
|
5105
|
+
error: error instanceof Error ? error.message : String(error)
|
|
5106
|
+
});
|
|
5107
|
+
}
|
|
5108
|
+
return;
|
|
5109
|
+
}
|
|
5110
|
+
if (req.method === "POST" && (pathname === "/advisor-group/resume" || pathname === "/advisor-group/resume/")) {
|
|
5111
|
+
try {
|
|
5112
|
+
const raw = await readBody(req);
|
|
5113
|
+
const parsed = JSON.parse(raw);
|
|
5114
|
+
if (typeof parsed.sessionId !== "string" || !parsed.sessionId) {
|
|
5115
|
+
sendJson(res, 400, {
|
|
5116
|
+
ok: false,
|
|
5117
|
+
error: "缺少 sessionId"
|
|
5118
|
+
});
|
|
5119
|
+
return;
|
|
5120
|
+
}
|
|
5121
|
+
const result = service.resumeConsultation(parsed.sessionId);
|
|
5122
|
+
sendJson(res, result.ok ? 200 : 400, {
|
|
5123
|
+
ok: result.ok,
|
|
5124
|
+
...result.reason ? { error: result.reason } : {}
|
|
5125
|
+
});
|
|
5126
|
+
} catch (error) {
|
|
5127
|
+
console.warn("[dsh-advisor-group] resume 请求处理失败:", error instanceof Error ? error.message : String(error));
|
|
5128
|
+
sendJson(res, 400, {
|
|
5129
|
+
ok: false,
|
|
5130
|
+
error: error instanceof Error ? error.message : String(error)
|
|
5131
|
+
});
|
|
5132
|
+
}
|
|
5133
|
+
return;
|
|
5134
|
+
}
|
|
2440
5135
|
if (req.method === "GET" && (pathname === "/advisor-group/shadow" || pathname === "/advisor-group/shadow/")) {
|
|
2441
5136
|
sendJson(res, 200, {
|
|
2442
5137
|
ok: true,
|
|
@@ -2445,12 +5140,20 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
|
|
|
2445
5140
|
return;
|
|
2446
5141
|
}
|
|
2447
5142
|
if (req.method === "GET" && (pathname === "/advisor-group/config" || pathname === "/advisor-group/config/")) {
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
5143
|
+
try {
|
|
5144
|
+
sendJson(res, 200, {
|
|
5145
|
+
ok: true,
|
|
5146
|
+
config: sanitizeConfig(service.getConfig()),
|
|
5147
|
+
revision: currentRevision(scopedCtx, namespace),
|
|
5148
|
+
dailyGuard: service.getDailyGuard()
|
|
5149
|
+
});
|
|
5150
|
+
} catch (error) {
|
|
5151
|
+
console.error("[dsh-advisor-group] config read failed:", error instanceof Error ? error.message : String(error));
|
|
5152
|
+
sendJson(res, 500, {
|
|
5153
|
+
ok: false,
|
|
5154
|
+
error: error instanceof Error ? error.message : String(error)
|
|
5155
|
+
});
|
|
5156
|
+
}
|
|
2454
5157
|
return;
|
|
2455
5158
|
}
|
|
2456
5159
|
if (req.method === "POST" && (pathname === "/advisor-group/config" || pathname === "/advisor-group/config/")) {
|
|
@@ -2459,7 +5162,7 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
|
|
|
2459
5162
|
const parsed = JSON.parse(raw);
|
|
2460
5163
|
const configInput = parsed.config ?? parsed;
|
|
2461
5164
|
const expectedRevision = typeof parsed.expectedRevision === "number" ? parsed.expectedRevision : currentRevision(scopedCtx, namespace);
|
|
2462
|
-
const reconciled = reconcileApiKeys(Config(configInput), service.getConfig());
|
|
5165
|
+
const reconciled = reconcileApiKeys(unwrapVolatileConfig(Config(configInput)), service.getConfig());
|
|
2463
5166
|
const securityError = validateSecurity(reconciled);
|
|
2464
5167
|
if (securityError) {
|
|
2465
5168
|
sendJson(res, 400, {
|
|
@@ -2468,8 +5171,10 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
|
|
|
2468
5171
|
});
|
|
2469
5172
|
return;
|
|
2470
5173
|
}
|
|
2471
|
-
|
|
2472
|
-
|
|
5174
|
+
const guarded = guardAdvisorPrompts(reconciled);
|
|
5175
|
+
reportPromptGuardFindings(guarded.findings);
|
|
5176
|
+
await scopedCtx.settings.replace(namespace, guarded.config, expectedRevision);
|
|
5177
|
+
service.setConfig(guarded.config);
|
|
2473
5178
|
sendJson(res, 200, {
|
|
2474
5179
|
ok: true,
|
|
2475
5180
|
config: sanitizeConfig(service.getConfig()),
|
|
@@ -2485,7 +5190,7 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
|
|
|
2485
5190
|
}
|
|
2486
5191
|
sendJson(res, 400, {
|
|
2487
5192
|
ok: false,
|
|
2488
|
-
error: error instanceof Error ? error.message : String(error)
|
|
5193
|
+
error: `保存失败:请检查刚才修改项的格式(原始信息:${error instanceof Error ? error.message : String(error)})`
|
|
2489
5194
|
});
|
|
2490
5195
|
}
|
|
2491
5196
|
return;
|
|
@@ -2533,14 +5238,16 @@ function registerAdvisorSettingsAndRoutes(ctx, config, service) {
|
|
|
2533
5238
|
if (!inject) return;
|
|
2534
5239
|
inject(["settings", "webServer"], (scoped) => {
|
|
2535
5240
|
const scopedCtx = scoped;
|
|
2536
|
-
const namespace = "advisor-group";
|
|
2537
|
-
|
|
2538
|
-
|
|
5241
|
+
const namespace = "dsh-advisor-group";
|
|
5242
|
+
service.setConfig(config);
|
|
5243
|
+
const presentationDisposer = scopedCtx.settings.configure({ auto: false }, ctx.fiber);
|
|
2539
5244
|
service.setPersistEnabled(async (enabled) => {
|
|
2540
5245
|
await scopedCtx.settings.update(namespace, { enabled });
|
|
2541
5246
|
});
|
|
2542
|
-
const watchDisposer =
|
|
2543
|
-
|
|
5247
|
+
const watchDisposer = ctx.on("loader/volatile-update", () => {
|
|
5248
|
+
const guarded = guardAdvisorPrompts(unwrapVolatileConfig(ctx.fiber.config));
|
|
5249
|
+
reportPromptGuardFindings(guarded.findings);
|
|
5250
|
+
service.setConfig(guarded.config);
|
|
2544
5251
|
});
|
|
2545
5252
|
const authToken = randomUUID();
|
|
2546
5253
|
const indexTapDisposer = scopedCtx.webServer.tapIndex ? scopedCtx.webServer.tapIndex((html) => html.replace("<head>", `<head><script>globalThis.__ADVISOR_GROUP_TOKEN__ = ${JSON.stringify(authToken)}<\/script>`)) : null;
|
|
@@ -2553,6 +5260,7 @@ function registerAdvisorSettingsAndRoutes(ctx, config, service) {
|
|
|
2553
5260
|
watchDisposer();
|
|
2554
5261
|
routeDisposer();
|
|
2555
5262
|
indexTapDisposer?.();
|
|
5263
|
+
presentationDisposer();
|
|
2556
5264
|
}, "dsh-advisor-group: settings watcher + config routes");
|
|
2557
5265
|
});
|
|
2558
5266
|
}
|
|
@@ -2598,7 +5306,7 @@ function buildAdvisorPrompt(config, repeatPressure) {
|
|
|
2598
5306
|
/**
|
|
2599
5307
|
* Host-only registration for the advisor-group session event vocabulary.
|
|
2600
5308
|
*
|
|
2601
|
-
* The official
|
|
5309
|
+
* The official compatibility mechanism is the per-event
|
|
2602
5310
|
* `SessionEvent.ignorable: true` envelope marker: `dsh-session-persistence`
|
|
2603
5311
|
* refuses unknown event types unless `KNOWN_SESSION_EVENT_TYPES.has(type)`
|
|
2604
5312
|
* or the stored event is marked ignorable. However `Session.append()` exposes
|
|
@@ -2615,7 +5323,8 @@ const ADVISOR_GROUP_EVENT_TYPES = [
|
|
|
2615
5323
|
"advisor-group/start",
|
|
2616
5324
|
"advisor-group/message",
|
|
2617
5325
|
"advisor-group/delta",
|
|
2618
|
-
"advisor-group/end"
|
|
5326
|
+
"advisor-group/end",
|
|
5327
|
+
"advisor-group/resume"
|
|
2619
5328
|
];
|
|
2620
5329
|
function ensureAdvisorGroupSessionEventTypes() {
|
|
2621
5330
|
const known = KNOWN_SESSION_EVENT_TYPES;
|
|
@@ -2630,12 +5339,15 @@ const name = "dsh-advisor-group";
|
|
|
2630
5339
|
const inject = [
|
|
2631
5340
|
"tools",
|
|
2632
5341
|
"llm",
|
|
2633
|
-
"systemPrompt"
|
|
5342
|
+
"systemPrompt",
|
|
5343
|
+
"sessions"
|
|
2634
5344
|
];
|
|
2635
5345
|
function apply(ctx, config) {
|
|
2636
|
-
const
|
|
2637
|
-
|
|
2638
|
-
|
|
5346
|
+
const guarded = guardAdvisorPrompts(unwrapVolatileConfig(config));
|
|
5347
|
+
reportPromptGuardFindings(guarded.findings);
|
|
5348
|
+
const service = new AdvisorGroupService(ctx, guarded.config);
|
|
5349
|
+
registerAdvisorSettingsAndRoutes(ctx, guarded.config, service);
|
|
5350
|
+
for (const tool of registerAdvisorTools(service, ctx)) ctx.tools.register(tool);
|
|
2639
5351
|
const systemPrompt = ctx.systemPrompt;
|
|
2640
5352
|
if (systemPrompt) ctx.effect(() => systemPrompt.section({
|
|
2641
5353
|
name: "advisor-group-boundary",
|