dsh-advisor-group 0.1.1 → 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/lib/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import Schema from "@deepseek-ai/schemastery";
2
2
  import { randomUUID } from "node:crypto";
3
- import { mkdirSync, promises, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } 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
5
  import { dirname, isAbsolute, join } from "node:path";
6
6
  import { KNOWN_SESSION_EVENT_TYPES, Session } from "@deepseek-ai/dsh-session";
@@ -1439,6 +1439,61 @@ function appendAdvisorEnd(log, session, summary) {
1439
1439
  };
1440
1440
  log.append("advisor-group/end", data);
1441
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
+ }
1442
1497
  //#endregion
1443
1498
  //#region src/stream-channel.ts
1444
1499
  const BOOT_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -3136,265 +3191,1140 @@ async function classifyWithJev(question, context, advisors, config, signal, ques
3136
3191
  }
3137
3192
  }
3138
3193
  //#endregion
3139
- //#region src/tools.ts
3140
- function registerAdvisorTools(service) {
3141
- return [defineTool({
3142
- name: "ask_advisors",
3143
- 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.",
3144
- parameters: {
3145
- question: {
3146
- type: "string",
3147
- description: "The question to ask the advisors. Required when starting a new consultation."
3148
- },
3149
- context: {
3150
- type: "string",
3151
- 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."
3152
- },
3153
- sessionId: {
3154
- type: "string",
3155
- description: "Existing consultation session id to continue."
3156
- },
3157
- followUp: {
3158
- type: "string",
3159
- description: "Follow-up question to send to the advisors in an existing session."
3160
- },
3161
- advisorIds: {
3162
- type: "array",
3163
- items: { type: "string" },
3164
- description: "Optional advisor ids to include; defaults to all configured advisors (capped by maxAdvisorsPerCall)."
3165
- },
3166
- confidence: {
3167
- type: "number",
3168
- description: "Optional self-assessed confidence 0-1. Values below the configured threshold escalate even without domain keywords."
3169
- },
3170
- questionEn: {
3171
- type: "string",
3172
- 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."
3173
- }
3174
- },
3175
- output: {
3176
- schema: {
3177
- type: "object",
3178
- additionalProperties: false,
3179
- properties: {
3180
- sessionId: {
3181
- type: "string",
3182
- required: true
3183
- },
3184
- advice: {
3185
- type: "string",
3186
- required: true
3187
- },
3188
- skipped: { type: "boolean" },
3189
- reason: { type: "string" }
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;
3245
+ }
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
+ });
3257
+ }
3258
+ /**
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.
3263
+ */
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()}`;
3273
+ }
3274
+ /**
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.
3278
+ */
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) {
3286
+ return {
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
3190
3500
  }
3191
- },
3192
- render: (_args, value) => [{
3193
- type: "text",
3194
- text: value.advice
3195
- }]
3196
- },
3197
- async execute(args, exec) {
3198
- const cfg = service.getConfig();
3199
- if (args.confidence !== void 0 && (args.confidence < 0 || args.confidence > 1)) return {
3200
- sessionId: "",
3201
- advice: "confidence 参数必须在 0-1 之间。",
3202
- skipped: true,
3203
- reason: "invalid-confidence"
3204
3501
  };
3205
- if (!service.isEnabled()) return {
3206
- sessionId: "",
3207
- advice: "顾问群插件已禁用。请先调用 toggle_advisor_group 启用。",
3208
- skipped: true,
3209
- reason: "disabled"
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: "本次调用被取消,等待中止;没有取消对方的回合。"
3210
3627
  };
3211
- if (cfg.advisors.length === 0) return {
3212
- sessionId: "",
3213
- advice: "顾问群尚未配置任何顾问。请先到 设置 → 插件 → 顾问群 添加顾问。",
3214
- skipped: true,
3215
- reason: "no-advisors"
3628
+ return {
3629
+ endedBecause: "error",
3630
+ lastRunning: targetRunning,
3631
+ note: `等待期间读取会话列表失败(${describeHostError(error)}),已返回当前区间内收集到的内容。`
3216
3632
  };
3217
- const withoutModel = advisorsMissingModel(cfg);
3218
- if (withoutModel.length > 0) return {
3219
- sessionId: "",
3220
- advice: `顾问群中有 ${withoutModel.length} 位顾问未配置模型(${withoutModel.map((a) => a.name).join("、")})。请先到 设置 → 插件 → 顾问群 补全模型后再发起,避免整轮调用失败。`,
3221
- skipped: true,
3222
- reason: "advisor-model-missing"
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: "等待结束:该会话已追加新内容且当前不忙(对方的回合发生在两次轮询之间,忙的状态未被采到)。"
3223
3651
  };
3224
- const sessionLog = exec.agent?.session;
3225
- const questionText = typeof args.question === "string" ? args.question : "";
3226
- const contextText = typeof args.context === "string" ? args.context : "";
3227
- const mentionedAdvisorGroup = /@顾问群|@顧問群/.test(`${questionText}\n${contextText}`);
3228
- const repeatCount = service.getRepeatPressure(sessionLog?.id)?.count ?? 0;
3229
- const forcedByRepeat = repeatCount >= 3;
3230
- let shadow;
3231
- let shadowWritten = false;
3232
- const recordShadow = (launched) => {
3233
- if (!shadow || shadowWritten) return;
3234
- shadowWritten = true;
3235
- appendShadowSample({
3236
- ...shadow,
3237
- launched
3238
- });
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)}`
3239
3740
  };
3240
- if (args.sessionId) {
3241
- if (!args.followUp) return {
3242
- sessionId: args.sessionId,
3243
- advice: "继续追问需要提供 followUp 参数。",
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 之间。",
3244
3917
  skipped: true,
3245
- reason: "missing-follow-up"
3918
+ reason: "invalid-confidence"
3246
3919
  };
3247
- const session = service.getSession(args.sessionId);
3248
- if (!session) return {
3249
- sessionId: args.sessionId,
3250
- advice: "顾问群会话不存在或已过期,请重新发起 ask_advisors。",
3920
+ if (!service.isEnabled()) return {
3921
+ sessionId: "",
3922
+ advice: "顾问群插件已禁用。请先调用 toggle_advisor_group 启用。",
3251
3923
  skipped: true,
3252
- reason: "session-not-found"
3924
+ reason: "disabled"
3253
3925
  };
3254
- if (service.isSessionRunning(session.id)) return {
3255
- sessionId: session.id,
3256
- advice: "该顾问群会话正在运行中,请等本轮结束后再追问。",
3926
+ if (cfg.advisors.length === 0) return {
3927
+ sessionId: "",
3928
+ advice: "顾问群尚未配置任何顾问。请先到 设置 → 插件 → 顾问群 添加顾问。",
3257
3929
  skipped: true,
3258
- reason: "session-running"
3930
+ reason: "no-advisors"
3259
3931
  };
3260
- if (service.hasReachedMaxRounds(session)) return {
3261
- sessionId: session.id,
3262
- advice: "本轮已是顾问群讨论的最后一轮,请综合现有顾问意见给出最终答复,不要再追问。",
3932
+ const withoutModel = advisorsMissingModel(cfg);
3933
+ if (withoutModel.length > 0) return {
3934
+ sessionId: "",
3935
+ advice: `顾问群中有 ${withoutModel.length} 位顾问未配置模型(${withoutModel.map((a) => a.name).join("、")})。请先到 设置 → 插件 → 顾问群 补全模型后再发起,避免整轮调用失败。`,
3263
3936
  skipped: true,
3264
- reason: "max-rounds-reached"
3265
- };
3266
- service.appendFollowUp(session.id, args.followUp, sessionLog);
3267
- const summary = await service.runOneRoundAndSummarize(session, exec.signal, sessionLog, exec.agent);
3268
- const canContinue = !service.hasReachedMaxRounds(session);
3269
- return {
3270
- sessionId: session.id,
3271
- advice: formatConversation(session) + formatRiskNotes(summary) + (canContinue ? "\n\n(如需继续,可再次传入 sessionId 和 followUp 追问。)" : "\n\n(讨论轮数已达上限,请综合顾问意见给出最终答复。)"),
3272
- skipped: false
3937
+ reason: "advisor-model-missing"
3273
3938
  };
3274
- }
3275
- if (!args.question) return {
3276
- sessionId: "",
3277
- advice: "发起新讨论需要提供 question 参数。",
3278
- skipped: true,
3279
- reason: "missing-question"
3280
- };
3281
- if (cfg.trigger.requireClassifier && !mentionedAdvisorGroup && !forcedByRepeat) {
3282
- let classification;
3283
- let jevResult;
3284
- let jevError;
3285
- let jevLatencyMs;
3286
- const jev = cfg.trigger.jev;
3287
- if (jev?.enabled) {
3288
- const jevStartedAt = Date.now();
3289
- try {
3290
- jevResult = await classifyWithJev(args.question, args.context, cfg.advisors, jev, exec.signal, typeof args.questionEn === "string" ? args.questionEn : void 0);
3291
- classification = jevResult;
3292
- } catch (error) {
3293
- if (exec.signal.aborted) throw error;
3294
- jevError = error instanceof Error ? error.message : String(error);
3295
- console.warn("[dsh-advisor-group] Jev 分类失败,回退到本地分类器:", sessionLog?.id ?? "no-session", jevError);
3296
- } finally {
3297
- jevLatencyMs = Date.now() - jevStartedAt;
3298
- }
3299
- }
3300
- classification ??= classifyRequest(args.question, args.context, cfg.advisors, args.confidence, cfg.trigger.confidenceThreshold);
3301
- shadow = {
3302
- ts: Date.now(),
3303
- question: args.question,
3304
- ...typeof args.confidence === "number" ? { confidence: args.confidence } : {},
3305
- shouldEscalate: classification.shouldEscalate,
3306
- reason: classification.reason,
3307
- suggestWebSearch: classification.suggestWebSearch,
3308
- provider: jevResult ? "jev" : "local",
3309
- ...jevError === void 0 ? {} : { jevError },
3310
- ...jevLatencyMs === void 0 ? {} : { jevLatencyMs },
3311
- ...repeatCount > 0 ? { repeatCount } : {},
3312
- ...jevResult?.rawAnswers ? { scores: jevResult.rawAnswers } : {},
3313
- ...jevResult?.model ? { model: jevResult.model } : {}
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
+ });
3314
3954
  };
3315
- if (!classification.shouldEscalate) {
3316
- recordShadow(false);
3317
- if (classification.suggestWebSearch && cfg.trigger.allowWebFallback) return {
3318
- sessionId: "",
3319
- advice: `前置分类器建议不启动顾问群:${classification.reason}`,
3955
+ if (args.sessionId) {
3956
+ if (!args.followUp) return {
3957
+ sessionId: args.sessionId,
3958
+ advice: "继续追问需要提供 followUp 参数。",
3320
3959
  skipped: true,
3321
- reason: "web-search-recommended"
3960
+ reason: "missing-follow-up"
3322
3961
  };
3323
- return {
3324
- sessionId: "",
3325
- advice: `前置分类器建议不启动顾问群:${classification.reason}`,
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: "本轮已是顾问群讨论的最后一轮,请综合现有顾问意见给出最终答复,不要再追问。",
3326
3978
  skipped: true,
3327
- reason: "classifier-rejected"
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
3328
3988
  };
3329
3989
  }
3330
- } else if (cfg.trigger.requireClassifier) shadow = {
3331
- ts: Date.now(),
3332
- question: args.question,
3333
- shouldEscalate: true,
3334
- reason: mentionedAdvisorGroup ? "用户点名 @顾问群,跳过前置分类器。" : `同一问题已重复 ${repeatCount} 次,强制升级跳过前置分类器。`,
3335
- suggestWebSearch: false,
3336
- provider: "bypass",
3337
- bypass: mentionedAdvisorGroup ? "mention" : "repeat",
3338
- repeatCount
3339
- };
3340
- if (args.advisorIds && args.advisorIds.length > 0) {
3341
- const configuredIds = new Set(cfg.advisors.map((advisor) => advisor.id));
3342
- if (args.advisorIds.filter((id) => configuredIds.has(id)).length === 0) {
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) {
3343
4069
  recordShadow(false);
3344
4070
  return {
3345
4071
  sessionId: "",
3346
- advice: "未匹配到任何指定顾问,请检查 advisorIds 是否与设置中的顾问 id 一致。",
4072
+ advice: costGuard.reason,
3347
4073
  skipped: true,
3348
- reason: "no-matching-advisors"
4074
+ reason: "daily-limit-reached"
3349
4075
  };
3350
4076
  }
3351
- }
3352
- const costGuard = service.tryStartConsultation();
3353
- if (!costGuard.ok) {
3354
- recordShadow(false);
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);
3355
4081
  return {
3356
- sessionId: "",
3357
- advice: costGuard.reason,
3358
- skipped: true,
3359
- reason: "daily-limit-reached"
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
3360
4085
  };
3361
4086
  }
3362
- const session = service.createSession(args.question, args.context, args.advisorIds, sessionLog ? sessionLog.header.cwd : void 0, sessionLog ? sessionLog.id : void 0);
3363
- if (sessionLog) appendAdvisorStart(sessionLog, session);
3364
- recordShadow(true);
3365
- const summary = await service.runAutoPipeline(session, exec.signal, sessionLog, exec.agent);
3366
- return {
3367
- sessionId: session.id,
3368
- advice: formatConversation(session) + (summary.conclusion ? `\n\n【综合结论】\n${summary.conclusion}` : "") + formatRiskNotes(summary) + (service.hasReachedMaxRounds(session) ? "\n\n(已达设置的最大讨论轮数,请参考上述顾问意见与综合结论给出最终答复。)" : ""),
3369
- skipped: false
3370
- };
3371
- }
3372
- }), defineTool({
3373
- name: "toggle_advisor_group",
3374
- description: "Enable or disable the advisor group plugin at runtime. Omit enabled to toggle the current state.",
3375
- parameters: { enabled: {
3376
- type: "boolean",
3377
- description: "Optional desired state."
3378
- } },
3379
- output: {
3380
- schema: {
3381
- type: "object",
3382
- additionalProperties: false,
3383
- properties: { enabled: {
3384
- type: "boolean",
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.",
3385
4121
  required: true
3386
- } }
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
+ }
3387
4136
  },
3388
- render: (_args, value) => [{
3389
- type: "text",
3390
- text: `顾问群插件已${value.enabled ? "启用" : "禁用"}。`
3391
- }]
3392
- },
3393
- async execute(args) {
3394
- const next = typeof args.enabled === "boolean" ? args.enabled : !service.isEnabled();
3395
- return { enabled: await service.toggleEnabled(next) };
3396
- }
3397
- })];
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
+ };
3398
4328
  }
3399
4329
  function formatRiskNotes(summary) {
3400
4330
  return summary.riskNotes.length > 0 ? `\n\n【风险提示】\n${summary.riskNotes.join("\n")}` : "";
@@ -4040,12 +4970,95 @@ function handleStreamRequest(req, res) {
4040
4970
  subscribe(sessionId, res, lastEventId, bootId);
4041
4971
  });
4042
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
+ }
4043
5040
  function handleConfigRequest(ctx, scopedCtx, service, authToken) {
4044
5041
  const namespace = "dsh-advisor-group";
4045
5042
  return async (req, res) => {
4046
5043
  const rawUrl = req.url ?? "";
4047
5044
  const url = new URL(rawUrl, "http://localhost");
4048
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
+ }
4049
5062
  const token = String(req.headers["x-advisor-group-token"] ?? url.searchParams.get("token") ?? "");
4050
5063
  if (authToken && token !== authToken) {
4051
5064
  sendJson(res, 401, {
@@ -4334,7 +5347,7 @@ function apply(ctx, config) {
4334
5347
  reportPromptGuardFindings(guarded.findings);
4335
5348
  const service = new AdvisorGroupService(ctx, guarded.config);
4336
5349
  registerAdvisorSettingsAndRoutes(ctx, guarded.config, service);
4337
- for (const tool of registerAdvisorTools(service)) ctx.tools.register(tool);
5350
+ for (const tool of registerAdvisorTools(service, ctx)) ctx.tools.register(tool);
4338
5351
  const systemPrompt = ctx.systemPrompt;
4339
5352
  if (systemPrompt) ctx.effect(() => systemPrompt.section({
4340
5353
  name: "advisor-group-boundary",