dsh-plugin-tool-management 0.18.0 → 0.19.0

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.
@@ -7,8 +7,8 @@
7
7
  // 三组候选,各自的失效模式不同:
8
8
  // * **技能 / 记忆 / 场景**(skillRows / memoryCandidates / memorySceneCandidates)——
9
9
  // 直接读各自 service 的 op,无缓存;
10
- // * **预设工具**(presetToolCandidates / presetToolNames)—— 枚举会为预设建立 standing
11
- // mount(官方语义:每进程一次),所以按 PRESET_ENUM_CACHE_MS 缓存,不能每次重枚举;
10
+ // * **预设工具**(presetToolCandidates / presetToolNames)—— 每次枚举要为每个预设取一次
11
+ // 官方 `acquireScope` 租约 + 读一次 `tools.schemas()`,所以按 PRESET_ENUM_CACHE_MS 缓存;
12
12
  // * **模型**(modelCandidates)—— 只读宿主 LLM 目录,**不发网络请求**。
13
13
  //
14
14
  // 依赖全部经 deps 显式传入。注意 \`toolKeyParts\` 留在 index.ts:场景档案引擎也在用它,
@@ -28,7 +28,7 @@ import { injectionFactsOf, readCompositionFacts, readCompositionText } from '../
28
28
  const MODEL_REASONING_TIMEOUT_MS = 10_000;
29
29
  export function createCandidates(deps) {
30
30
  // 外部能力一次解构成局部名:块内代码逐字搬来,保持原样最不容易出错。
31
- const { get, tools, mcp, skillsService, memoriesService, toolKeyParts } = deps;
31
+ const { get, tools, mcp, skillsService, memoriesService, toolKeyParts, selfToolNames } = deps;
32
32
  const ctx = { get };
33
33
  // Agent 预设名单(@deepseek-ai/dsh-agent-presets):同样是可选服务,按需取用。
34
34
  // 读它只为了回答"当前预设下本插件注入的东西到不到得了模型"——只读,不挂载任何预设。
@@ -195,31 +195,25 @@ export function createCandidates(deps) {
195
195
  }
196
196
  }
197
197
  /**
198
- * 工具候选(人设的「工具白名单 / 黑名单」选择器):
199
- * 取**全体 Agent 预设工具名的并集**——人设可能在任意预设下被子代理复用,
200
- * 只列当前会话的工具会让换预设后的子代理启动失败(官方 `toolFilter` 对未知名直接拒绝)。
201
- * 同时标注 `current`:当前会话可见的工具(其余只是「本预设可用,当前会话看不到」)。
198
+ * 工具候选(人设的「工具白名单 / 黑名单」选择器):一行一个 Agent 预设,各带**它自己的**
199
+ * 工具清单与归属组。
202
200
  *
203
- * 每次调用都会为尚未挂载的预设建立 standing mount(官方语义:一个预设在本进程内只挂一次,
204
- * 正常创建会话时同样会挂),因此结果会按需缓存 PRESET_ENUM_CACHE_MS,避免频繁枚举。
201
+ * 为什么按预设切而不是给一份并集:人设可能在任意预设下被子代理复用,而界面一行只对那个
202
+ * 预设生效,所以勾出来的名字必须属于那一行。见 `presetToolView`(判据与 0.19.0 修掉的
203
+ * 那条断链)。
204
+ *
205
+ * 0.19.0 删掉了原来那份「全体并集」(`tools`,含 `presets` / `current` 两个字段):整条链
206
+ * 上零读取点(客户端只读 `presets`),而它是同一份数据的第二个真相 —— 从 `presets[].tools`
207
+ * 直接推得出来,每次调用白背 ≈2KB 载荷。
205
208
  */
206
- // 两个预设枚举缓存(全体候选 / 单个预设)共用这个 TTL:枚举的代价是"给预设建立
207
- // standing mount",与内容变化无关,所以只要别刷得太勤就行。
209
+ // 两个预设枚举缓存(全体候选 / 单个预设)共用这个 TTL:枚举要一次 acquireScope 租约 +
210
+ // 一次 schemas(),与内容变化无关,所以只要别刷得太勤就行。
208
211
  const PRESET_ENUM_CACHE_MS = 60_000;
209
212
  let presetToolsCache = null;
210
213
  async function presetToolCandidates() {
211
214
  if (presetToolsCache && Date.now() - presetToolsCache.at < PRESET_ENUM_CACHE_MS)
212
215
  return presetToolsCache.value;
213
- const byName = new Map();
214
216
  const presets = [];
215
- // 当前会话的可见工具(用于标 current);拿不到就全部按「非当前」处理。
216
- const currentNames = new Set();
217
- try {
218
- const schemas = await tools.schemas();
219
- for (const s of schemas || [])
220
- currentNames.add(String(s.name));
221
- }
222
- catch { /* 无 live 工具 → current 全 false */ }
223
217
  const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
224
218
  if (agentPresets && typeof agentPresets.list === 'function') {
225
219
  try {
@@ -230,9 +224,10 @@ export function createCandidates(deps) {
230
224
  const id = String((p && p.id) || '');
231
225
  if (!id)
232
226
  continue;
227
+ const view = await presetToolView(id);
233
228
  // MCP 工具名形如 `mcp__<server>__<tool>`:面向上百个条目,噪声大于价值 → 不进候选。
234
229
  // 子代理照样能用当前在跑的 MCP —— 那份名单在 decideToolFilter 里运行时并进白名单。
235
- const names = (await presetToolNames(id)).filter((name) => !name.startsWith(MCP_TOOL_PREFIX));
230
+ const names = view.names.filter((name) => !name.startsWith(MCP_TOOL_PREFIX));
236
231
  presets.push({
237
232
  id,
238
233
  name: String((p && (p.name || p.id)) || id),
@@ -242,65 +237,150 @@ export function createCandidates(deps) {
242
237
  trust: String((p && p.trust) || ''),
243
238
  broken: typeof (p && p.broken) === 'string',
244
239
  tools: names,
240
+ // 与 tools 同序裁剪:被 MCP 前缀筛掉的名字不留在归属表里(这张表只描述列出来的那些)。
241
+ toolGroups: names.reduce((acc, name) => {
242
+ acc[name] = view.origins[name] ?? 'other';
243
+ return acc;
244
+ }, {}),
245
245
  });
246
- for (const name of names) {
247
- const rec = byName.get(name) || { name, presets: new Set(), current: false };
248
- rec.presets.add(id);
249
- byName.set(name, rec);
250
- }
251
246
  }
252
247
  }
253
- catch { /* 预设服务不可用 → 退回「仅当前会话工具」 */ }
248
+ catch { /* 预设服务不可用 → 一行都不给(界面显示读不到,不假装空) */ }
254
249
  }
255
- // 当前会话的工具即便没有任何预设可枚举,也要出现在候选里(否则选择器是空的)。
256
- for (const name of currentNames) {
257
- if (name.startsWith(MCP_TOOL_PREFIX))
258
- continue;
259
- const rec = byName.get(name) || { name, presets: new Set(), current: false };
260
- byName.set(name, rec);
261
- }
262
- for (const rec of byName.values())
263
- rec.current = currentNames.has(rec.name);
264
- const value = {
265
- tools: [...byName.values()]
266
- .map((r) => ({ name: r.name, presets: [...r.presets].sort(), current: r.current }))
267
- .sort((a, b) => a.name.localeCompare(b.name)),
268
- // 保持 roster 顺序(官方的展示顺序),前端四行直接照用。
269
- presets,
270
- };
250
+ const value = { presets };
271
251
  presetToolsCache = { at: Date.now(), value };
272
252
  return value;
273
253
  }
254
+ /** 官方包的作用域前缀。只用来判「这个预设的行是不是全部来自官方」,不做任何工具名匹配。 */
255
+ const OFFICIAL_PACKAGE_PREFIX = '@deepseek-ai/';
274
256
  /**
275
- * 单个预设的工具名(列表里含 MCP 与宿主平面的工具,因为子代理的可见集合是
276
- * "宿主平面 ∪ 该预设"的并集)。枚举会为该预设建立 standing mount —— 官方语义:
277
- * 一个预设每进程只挂一次,正常创建会话时同样会挂,所以这里按 id 缓存 PRESET_ENUM_CACHE_MS;
278
- * `subagent_manager_run` 每次委派都要用它校验名单,不能每次都重新枚举。
257
+ * 一个预设自己的工具视图 + 三组归属。
258
+ *
259
+ * 归属只能做到「层」粒度,这是**官方的限制而不是设计选择**:`ToolSchema` 只有
260
+ * `{ name, description, parameters, deferLoading? }`,没有归属字段;`ToolRuntime` 的公开面
261
+ * (`register` / `restrict` / `guard` / `get` / `schemas` / `presentAs`)里也没有「这个工具是谁
262
+ * 注册的」(`view` 是 private);`tools/change` 是**无载荷** emit,只报"变了";宿主侧的
263
+ * `dsh-host-plugin-inventory` 自述 "No layer attribution",且 Remote-only、进程内 `ctx.get`
264
+ * 拿不到。所以判据全部是集合运算,不出现任何工具名或包名的字面量:
265
+ *
266
+ * G = tools.schemas() 宿主平面(global 层)
267
+ * P = tools.schemas(acquireScope(id).key) 该预设视图 = G ∪ 该预设自己挂的包
268
+ * Δ = P − G 该预设自带的工具
269
+ * O = 本插件注册漏斗登记的名字 插件工具 = O ∩ P
270
+ *
271
+ * 「官方」在这里是**该预设自带的**,不是「DeepSeek 出的」:宿主平面上的官方工具(如
272
+ * `load_workspace_dependencies`)会落在「其他」。这是刻意的 —— 宁可说「不知道」,不假装是
273
+ * 官方;要认出来只能靠一份名字对照表,那会同时丢掉「零硬编码」与「随官方更新而更新」。
274
+ * 预设里混挂了第三方行时 Δ 无法再拆(包粒度已知、工具粒度未知),此时整份落「其他」。
279
275
  */
280
- const presetNamesCache = new Map();
281
- async function presetToolNames(id) {
282
- const hit = presetNamesCache.get(id);
276
+ const presetViewCache = new Map();
277
+ async function presetToolView(id) {
278
+ const hit = presetViewCache.get(id);
283
279
  if (hit && Date.now() - hit.at < PRESET_ENUM_CACHE_MS)
284
- return hit.names;
280
+ return hit.view;
281
+ // 宿主平面:一次读、所有预设共用。读不到 → 空集,于是 Δ = P,全部落「其他」而不是误判成官方。
282
+ const hostNames = new Set();
283
+ try {
284
+ for (const s of (await tools.schemas()) || []) {
285
+ const name = String(s?.name || '');
286
+ if (name)
287
+ hostNames.add(name);
288
+ }
289
+ }
290
+ catch { /* 读不到宿主平面 → Δ 退化成 P,全部落「其他」 */ }
285
291
  const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
286
- let scopeKey;
292
+ // 官方预设服务的公开面里,取"某个预设的 standing scope key"的只有 `acquireScope(id)`:
293
+ // 返回 `{ key: ScopeKey } & AsyncDisposable`,契约是**读完就 dispose**(内部 `retain()` 会
294
+ // `generation.users++`,不还这份 generation 永远回收不掉)。它**不挂载**预设:`retain` 对
295
+ // 未激活/损坏的预设直接抛 `RemoteError` —— 于是"读不到"与"没有工具"被分开了,后者不会
296
+ // 再冒充前者。此前的 `standingKeyFor` 在官方包里根本不存在(`@deepseek-ai` 全树 0 命中),
297
+ // 那条 `typeof` 守卫恒为假,等于一直在读全局视图。
298
+ const canScope = !!(agentPresets && typeof agentPresets.acquireScope === 'function');
299
+ let names = [];
300
+ if (canScope) {
301
+ let lease;
302
+ try {
303
+ lease = await agentPresets.acquireScope(id);
304
+ names = ((await tools.schemas(lease && lease.key)) || [])
305
+ .map((s) => String(s?.name || ''))
306
+ .filter(Boolean);
307
+ }
308
+ catch {
309
+ names = [];
310
+ }
311
+ finally {
312
+ // 还租约。dispose 自己幂等且吞异常,这里再包一层只为不让"还租约"失败盖掉真正的读失败。
313
+ try {
314
+ if (lease && typeof lease[Symbol.asyncDispose] === 'function')
315
+ await lease[Symbol.asyncDispose]();
316
+ }
317
+ catch { /* 已经还过 */ }
318
+ }
319
+ }
320
+ else {
321
+ // 老宿主没有 `acquireScope`:退回宿主平面(= 0.19.0 之前的可见行为),并且**什么都不认成
322
+ // 官方**。这比列一份空的强(用户至少还看得见、还能勾),也比拿全局清单冒充"该预设自己的"诚实。
323
+ names = [...hostNames];
324
+ }
325
+ // 本插件自报的名字。读不到 → 空集,于是本插件的工具落「其他」而不是凭空消失。
326
+ const self = new Set();
287
327
  try {
288
- scopeKey = agentPresets && typeof agentPresets.standingKeyFor === 'function'
289
- ? await agentPresets.standingKeyFor(id)
290
- : undefined;
328
+ for (const name of selfToolNames())
329
+ self.add(String(name));
291
330
  }
292
- catch {
293
- scopeKey = undefined;
331
+ catch { /* 读不到自报名单 */ }
332
+ const shipped = canScope ? await presetIsShipped(id) : false;
333
+ const origins = {};
334
+ for (const name of names) {
335
+ if (self.has(name))
336
+ origins[name] = 'plugin';
337
+ // 只有该预设的行**全部**来自官方包时才敢把差集叫「官方」;混挂第三方行时整份落「其他」。
338
+ else if (shipped && !hostNames.has(name))
339
+ origins[name] = 'official';
340
+ else
341
+ origins[name] = 'other';
294
342
  }
295
- let names = [];
343
+ const view = { names, origins };
344
+ presetViewCache.set(id, { at: Date.now(), view });
345
+ return view;
346
+ }
347
+ /**
348
+ * 该预设的行是不是**全部**来自 `@deepseek-ai/`(出厂四预设都是)。
349
+ *
350
+ * 读 `compositionInventory()`(官方公开方法,"Read plugin rows without creating an Agent")。
351
+ * 它是**包**粒度、不是工具粒度 —— 这正是本节开头那段限制的具体形状:拿得到"这个预设挂了
352
+ * 哪些包",拿不到"哪个包贡献了哪个工具"。所以只能整份判,判不了就整份落「其他」。
353
+ *
354
+ * 任何一步失败都返回 `false`(= 不认成官方),不抛。
355
+ */
356
+ const presetShippedCache = new Map();
357
+ async function presetIsShipped(id) {
358
+ const hit = presetShippedCache.get(id);
359
+ if (hit && Date.now() - hit.at < PRESET_ENUM_CACHE_MS)
360
+ return hit.shipped;
361
+ let shipped = false;
296
362
  try {
297
- names = ((await tools.schemas(scopeKey)) || []).map((s) => String(s.name)).filter(Boolean);
363
+ const agentPresets = (typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined);
364
+ if (agentPresets && typeof agentPresets.compositionInventory === 'function') {
365
+ const list = await agentPresets.compositionInventory();
366
+ const row = (list || []).filter((p) => p && String(p.id) === id)[0];
367
+ const rows = row && Array.isArray(row.rows) ? row.rows : [];
368
+ shipped = rows.length > 0 && rows.every((r) => String((r && r.moduleName) || '').startsWith(OFFICIAL_PACKAGE_PREFIX));
369
+ }
298
370
  }
299
371
  catch {
300
- names = [];
372
+ shipped = false;
301
373
  }
302
- presetNamesCache.set(id, { at: Date.now(), names });
303
- return names;
374
+ presetShippedCache.set(id, { at: Date.now(), shipped });
375
+ return shipped;
376
+ }
377
+ /**
378
+ * 单个预设的工具名(含 MCP 与宿主平面的工具,因为子代理的可见集合是
379
+ * "宿主平面 ∪ 该预设"的并集)。`subagent_manager_run` 每次委派都要用它校验名单,所以
380
+ * 按 id 缓存 PRESET_ENUM_CACHE_MS。只取名字 —— 归属那份走 `presetToolView`。
381
+ */
382
+ async function presetToolNames(id) {
383
+ return (await presetToolView(id)).names;
304
384
  }
305
385
  /** 预设名单的名字投影(只读 roster,不挂载任何预设):场景页显示模式名用。 */
306
386
  async function presetNames() {
@@ -318,32 +398,45 @@ export function createCandidates(deps) {
318
398
  }
319
399
  }
320
400
  /**
321
- * 模型候选(人设的「模型」下拉):宿主已注册的 provider + 各 provider 能宣告的模型。
322
- * 只读宿主 LLM 目录,**不发起网络请求**(`discoverModels` 会打端点,这里不用);
401
+ * 模型候选(人设的「模型」与「模型来源」两个下拉):宿主已注册的 provider + 各 provider
402
+ * 能宣告的模型。只读宿主 LLM 目录,**不发起网络请求**(`discoverModels` 会打端点,这里不用);
323
403
  * 拿不到就返回空列表,UI 退回手填(跨来源模型如 sensenova 的手工条目仍需手填兜底)。
324
- * 返回扁平列表:DSH 的模型路由是 (provider, model) 一对,两个键必须同时给。
404
+ *
405
+ * 两份投影、用途不同:
406
+ * · `models` 是扁平列表 —— DSH 的模型路由是 (provider, model) 一对,两个键必须同时给;
407
+ * · `providers` 是去重后的来源清单 —— 「模型来源」下拉要用它。
408
+ *
409
+ * `providers` 不能由 `models` 去重反推:适配器不提供 `listModels` 时该 provider 一个模型
410
+ * 都报不出来(官方 LlmAdapter 里它是可选项),而"来源有、目录里没模型"恰恰是最需要手填
411
+ * 模型 id 的场景 —— 由 models 反推会让这类 provider 在来源下拉里整个消失。
325
412
  */
326
413
  async function modelCandidates() {
327
414
  const llm = (typeof ctx.get === 'function' ? ctx.get('llm') : undefined);
328
415
  if (!llm || typeof llm.listProviders !== 'function')
329
- return { models: [] };
416
+ return { models: [], providers: [] };
330
417
  let list = [];
331
418
  try {
332
419
  list = llm.listProviders() || [];
333
420
  }
334
421
  catch {
335
- return { models: [] };
422
+ return { models: [], providers: [] };
336
423
  }
337
424
  const models = [];
425
+ const providers = [];
338
426
  const seen = new Set();
427
+ const seenProvider = new Set();
339
428
  for (const p of list) {
340
429
  const provider = String((p && (p.id || p.provider)) || '');
341
430
  if (!provider)
342
431
  continue;
343
432
  const providerName = String((p && (p.name || p.displayName)) || provider);
433
+ if (!seenProvider.has(provider)) {
434
+ seenProvider.add(provider);
435
+ providers.push({ id: provider, name: providerName });
436
+ }
344
437
  let discovered = [];
345
438
  try {
346
- // 适配器可选提供 listModels(官方 LlmAdapter 契约);未提供时该 provider 只报名字。
439
+ // 适配器可选提供 listModels(官方 LlmAdapter 契约);未提供时该 provider 只有来源那一项。
347
440
  if (typeof llm.listModels === 'function')
348
441
  discovered = (await llm.listModels(provider)) || [];
349
442
  }
@@ -359,7 +452,7 @@ export function createCandidates(deps) {
359
452
  models.push({ provider, providerName, id, name: String((m && m.name) || id) });
360
453
  }
361
454
  }
362
- return { models };
455
+ return { models, providers };
363
456
  }
364
457
  /**
365
458
  * 某个 (provider, model) 的思考强度档位。见 `Candidates.modelReasoning` 的注释。
@@ -4,7 +4,7 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { hubPath } from "../hub.js";
5
5
  import { sessionDir } from "@deepseek-ai/dsh-spill-local";
6
6
  import { trackTombstone } from "./tombstone.js";
7
- import { archivedBatchTargetSchema, workspaceIdSchema, } from "./archive-schemas.js";
7
+ import { archivedBatchTargetSchema, archivedSetSchema, workspaceIdSchema, } from "./archive-schemas.js";
8
8
  import { workspaceBaseName, workspacePathKey } from "./path-key.js";
9
9
  import { noteRuntime } from "../compat/runtime-notes.js";
10
10
  /**
@@ -242,6 +242,23 @@ var ArchiveWorkspaceRegistry = class {
242
242
  if (changed)
243
243
  await this.saveArchivedAt();
244
244
  }
245
+ /**
246
+ * 以宿主权威归档集合构造恢复回执。
247
+ *
248
+ * 官方 `unarchiveSession` 只回 `Promise<void>`,**宿主给什么一律不认**,委托与自持两条
249
+ * 分支共用这一个形状。读公开 getter 而不是 `requireState()`:后者经 `bridge.state()`
250
+ * 落到 `method("requireState")`,宿主缺这个私有成员时会直接抛(bridge.ts:331)——
251
+ * 恢复已经成功了,不该被一份回执拖成失败。
252
+ */
253
+ archivedSetResult() {
254
+ const raw = this.archivedSessionIds;
255
+ if (!Array.isArray(raw)) {
256
+ this.ctx.logger.warn("archive-manager: registry.archivedSessionIds is not an array; reporting an empty archive set");
257
+ return { archivedSessionIds: [] };
258
+ }
259
+ // 集合由我们自己拼,schema 在这里是后置断言:真触发说明 getter 的声明与运行时不符。
260
+ return archivedSetSchema.parse({ archivedSessionIds: [...new Set(raw.map((id) => String(id)))] });
261
+ }
245
262
  async ensureArchivedAtLoaded() {
246
263
  await this.ready;
247
264
  if (this.archivedAtLoaded)
@@ -689,9 +706,10 @@ var ArchiveWorkspaceRegistry = class {
689
706
  async unarchiveSession(sessionId) {
690
707
  this.bridge.checkWorkspace("unarchive");
691
708
  if (typeof this.registry.unarchiveSession === "function") {
692
- const result = await this.registry.unarchiveSession(sessionId);
709
+ await this.registry.unarchiveSession(sessionId);
693
710
  await this.reconcileArchiveLedger();
694
- return result;
711
+ // 官方只回 Promise<void>,返回值不认;回执按权威集合自造(见 archivedSetResult)。
712
+ return this.archivedSetResult();
695
713
  }
696
714
  return this.enqueueOperation(async () => {
697
715
  if (!(await this.sessionKnown(sessionId)))
@@ -757,6 +775,9 @@ var ArchiveWorkspaceRegistry = class {
757
775
  * 目标全部来自已归档集合,因此即使日志已被外部移除,也会清掉陈旧归档标记。
758
776
  */
759
777
  async unarchiveSessions(target) {
778
+ // ⚠️ 这一支今天**跑不到**:宿主没有 `unarchiveSessions`(dsh-workspace 全库零命中),
779
+ // 所以无从测试。它保留了「直接认宿主返回值」这个和 unarchiveSession 同型的过度信任 ——
780
+ // 官方哪天补上这个方法时,先照 unarchiveSession 的做法改成自造回执,别照抄这一行。
760
781
  if (typeof this.registry.unarchiveSessions === "function") {
761
782
  const result = await this.registry.unarchiveSessions(target);
762
783
  await this.reconcileArchiveLedger();
@@ -766,7 +787,7 @@ var ArchiveWorkspaceRegistry = class {
766
787
  const ids = this.archivedSessionIdsForTarget(target);
767
788
  for (const id of ids)
768
789
  await this.unarchiveSession(id);
769
- return { unarchivedSessionIds: ids, archivedSessionIds: [...this.registry.archivedSessionIds] };
790
+ return { unarchivedSessionIds: ids, ...this.archivedSetResult() };
770
791
  }
771
792
  return this.enqueueOperation(async () => {
772
793
  const unarchivedSessionIds = this.archivedSessionIdsForTarget(target);
@@ -14,7 +14,8 @@ export function createSettingsSidecars(deps) {
14
14
  // 注入设置(侧车 `inject-settings.json`,界面在「兼容」页):
15
15
  // - underSuppressingPresets:压制型预设(persona complete / 关运行时上下文,如极简)
16
16
  // 下是否仍然注入。默认 false = 跟随预设。
17
- // - domains:各域开关(任何预设下都生效;界面五个勾选)。
17
+ // - domains:各域开关(任何预设下都生效;界面六个勾选。深度判据不在这里 —— 见
18
+ // `InjectDomain.applicableTo`:场景与记忆只在顶层,子智能体目录按人设 catalogDepth)。
18
19
  // 注入器每个 step 都要同步读一次设置 → 走 TTL 缓存;未加载时先给默认值并异步预热。
19
20
  const INJECT_SETTINGS_FILE = 'inject-settings.json';
20
21
  const INJECT_SETTINGS_TTL_MS = 3000;
@@ -1,4 +1,4 @@
1
- import { catalogInjectedAt } from './service.js';
1
+ import { catalogInjectedAt, CONTINUABLE_MARK } from './service.js';
2
2
  import { subagentDepthOf } from '../context-inject.js';
3
3
  import { filterBySceneBinding } from './tools.js';
4
4
  /**
@@ -48,7 +48,7 @@ export function renderSubagentCatalog(allowed, maxEntries = DEFAULT_CATALOG_MAX_
48
48
  return '';
49
49
  const sorted = [...allowed].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
50
50
  const shown = sorted.slice(0, Math.max(0, maxEntries));
51
- const lines = shown.map((p) => '- **' + p.name + '** — ' + (catalogDescription(p.description, maxDescription) || NO_DESCRIPTION));
51
+ const lines = shown.map((p) => '- **' + p.name + '** — ' + (catalogDescription(p.description, maxDescription) || NO_DESCRIPTION) + (p.continuable === true ? CONTINUABLE_MARK : ''));
52
52
  const hidden = sorted.length - shown.length;
53
53
  // 只给清单。"这是什么"与"该拿它做什么"由注入通道的框架交代 —— 2026-09-18 起是
54
54
  // context-inject.ts 的 `DOMAIN_FRAME.subagents`(标题 + 加粗的动作句 + 工具名行)。
@@ -6,6 +6,16 @@ export function listOf(value) {
6
6
  const list = text.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
7
7
  return list.length ? list : undefined;
8
8
  }
9
+ /**
10
+ * frontmatter 里真值标志的写法(`continuable: true` / `yes` / `on` / `1`)。
11
+ *
12
+ * 只认这几种、且**只认真值**:写错的代价是不对称的 —— 少认一个词只是静默保持一次性
13
+ * (与今天的行为一致),而反过来(把 `false` 之类误判成真)会让这个角色突然改成异步回执,
14
+ * 模型拿到一个 id 却以为拿到了结果。所以判据取窄。
15
+ */
16
+ export function truthyFlag(value) {
17
+ return value !== undefined && /^(true|yes|on|1)$/i.test(String(value).trim());
18
+ }
9
19
  /**
10
20
  * 解析 `toolsByPreset:` 块 —— 每个 Agent 预设一行:
11
21
  *
@@ -43,7 +53,7 @@ export function parsePresetToolRules(frontmatter) {
43
53
  }
44
54
  return Object.keys(rules).length ? rules : undefined;
45
55
  }
46
- /** 行式 frontmatter 解析:只认 description / provider / model / tools / toolsDeny / toolsByPreset / catalogDepth / output。 */
56
+ /** 行式 frontmatter 解析:只认 description / provider / model / reasoningEffort / tools / toolsDeny / toolsByPreset / catalogDepth / output / continuable。 */
47
57
  /**
48
58
  * 思考强度档位 id 的归一:单行、去空白、限长。**不校验它是不是合法档位** —— 清单要问 adapter
49
59
  * 才拿得到(可能拿不到),而官方本来就会在 provider I/O 之前拒掉不支持的显式值。
@@ -95,6 +105,8 @@ export function parsePersona(raw, fallbackName) {
95
105
  toolsDeny,
96
106
  ...(toolsByPreset === undefined ? {} : { toolsByPreset }),
97
107
  ...(catalogDepth === undefined ? {} : { catalogDepth }),
108
+ // 允许追问:只有真值才落这个字段(缺省 = 一次性,与今天的行为一致)。
109
+ ...(truthyFlag(data.continuable) ? { continuable: true } : {}),
98
110
  ...(outputLines.length ? { output: outputLines.join('\n') } : {}),
99
111
  body: body.trim(),
100
112
  path: '',
@@ -179,10 +191,19 @@ export function renderPersonaPrompt(persona) {
179
191
  return name;
180
192
  const rawDescription = String(persona.description ?? '').trim();
181
193
  const output = neutralizePromptVariables(String(persona.output ?? '').trim());
194
+ // 身份定位句(0.19.0):此前只有"你正在以「X」的身份执行本次委派任务"一句,它说的是
195
+ // **角色**,没说**身份**。而子会话是整份 join 父会话预设的(官方 `composeFrom`,无分项
196
+ // 开关),于是 `~/.dsh/AGENTS.md` 那份"写给主代理的操作手册"也进了它的上下文 —— 通篇是
197
+ // "最新用户消息""向用户确认""开分支""等 review"。子代理据此认定自己是主代理。
198
+ // 官方的 `SUBAGENT_DELEGATION_CONTEXT` 只讲**权限范围**("your permission scope was
199
+ // fixed…"),不讲身份,所以补这句不是重复。
200
+ // 位置放最前:先定"你是谁",后面那段角色定义才是"你怎么做"。
182
201
  const lines = isChinesePersona(name, rawDescription, raw)
183
202
  ? [
184
203
  `# 角色:${name}`,
185
204
  '',
205
+ `你是被委派的子代理,不是主代理:只做调用方交给你的这一件事,产出交回调用方,不要向用户提问。`,
206
+ '',
186
207
  `你正在以「${name}」的身份执行本次委派任务。以下角色定义由调用方指定,是你本次运行的固定行为准则:与你的默认倾向冲突时,以它为准;任务说要什么,怎么做以它为准。它决定你如何工作,不改变你的权限范围。`,
187
208
  '',
188
209
  '## 角色定义',
@@ -193,6 +214,8 @@ export function renderPersonaPrompt(persona) {
193
214
  : [
194
215
  `# Persona: ${name}`,
195
216
  '',
217
+ `You are a delegated subagent, not the primary agent: do only the one thing the caller gave you, return the output to the caller, and do not ask the user anything.`,
218
+ '',
196
219
  `You are running this delegated task as "${name}". The persona below was specified by the caller and is your fixed operating guideline for this run: where it conflicts with your default inclinations, it wins; the task states what to achieve, and this persona governs how. It governs how you work, not what you are permitted to do.`,
197
220
  '',
198
221
  '## Persona',
@@ -204,6 +227,18 @@ export function renderPersonaPrompt(persona) {
204
227
  }
205
228
  /** 一个人设没写 `catalogDepth` 时的目录注入深度:1 —— 只在顶层注入目录。 */
206
229
  export const DEFAULT_PERSONA_CATALOG_DEPTH = 1;
230
+ /**
231
+ * 允许追问人设的标记(0.19.0),由人设目录与 `subagent_manager_list` 两处共用。
232
+ *
233
+ * 为什么标在目录行上:`continuable` 改变的是**委派的返回值形态**(回执一个 id 而不是产出),
234
+ * 模型必须在这一刻就知道 —— 否则它会按"等下会拿到结果"来安排后续动作,然后拿到一个 id
235
+ * 不知如何处理。标在这里的成本是**按需**的(没有人设开它就不出现),而写进工具描述是每轮
236
+ * 都付一遍。
237
+ *
238
+ * 放在本模块(叶子)而不是 `catalog.ts`:`tools.ts` 也要用它,而 `catalog.ts` 反向依赖
239
+ * `tools.ts` 的 `filterBySceneBinding` —— 放那边会形成 tools ↔ catalog 的循环。
240
+ */
241
+ export const CONTINUABLE_MARK = '(允许追问)';
207
242
  /**
208
243
  * 「不限制嵌套」的目录注入深度(界面下拉的第四档,前端同值见 client.js 的
209
244
  * `CATALOG_DEPTH_UNLIMITED`)。
@@ -3,7 +3,7 @@
3
3
  // 从 subagents/service.ts 整段搬来,一行未改。只写用户填过的键这条纪律留在函数自己的
4
4
  // 注释里;名字谓词仍收敛到 ../paths.ts,这里不另写一份。
5
5
  import { isValidSegment } from '../paths.js';
6
- import { DEFAULT_PERSONA_CATALOG_DEPTH, normalizeReasoningEffort } from './persona-parse.js';
6
+ import { DEFAULT_PERSONA_CATALOG_DEPTH, normalizeReasoningEffort, truthyFlag } from './persona-parse.js';
7
7
  /** 人设名长度上限(字符)。 */
8
8
  export const PERSONA_NAME_MAX = 64;
9
9
  /**
@@ -40,6 +40,10 @@ export function serializePersona(args) {
40
40
  : Number(String(catalogDepthRaw).trim());
41
41
  const hasCatalogDepth = catalogDepth !== undefined && Number.isSafeInteger(catalogDepth) && catalogDepth >= 0 && catalogDepth !== DEFAULT_PERSONA_CATALOG_DEPTH;
42
42
  const body = String((args && args.body) ?? '').trim();
43
+ // 允许追问:**只在真值时才写这一行**(与 `catalogDepth` 默认值同规矩)—— 免得每个人设都多一行
44
+ // 说明"它和默认一样",也保证老的人设文件回写后逐字节不变。入参兼容布尔与字符串两种形态
45
+ // (界面传布尔,手写/导入的 frontmatter 走解析侧已归一成布尔)。
46
+ const continuable = args?.continuable === true || truthyFlag(String(args?.continuable ?? ''));
43
47
  // `output` 可传字符串(按行拆)或数组(界面直接给数组):一条要求写成一行重复键。
44
48
  const outputLines = (Array.isArray(args?.output) ? args.output.map((v) => String(v)) : String(args?.output ?? '').split(/\r?\n/))
45
49
  .map((line) => line.trim())
@@ -55,6 +59,8 @@ export function serializePersona(args) {
55
59
  lines.push('reasoningEffort: ' + reasoningEffort);
56
60
  if (hasCatalogDepth)
57
61
  lines.push('catalogDepth: ' + String(catalogDepth));
62
+ if (continuable)
63
+ lines.push('continuable: true');
58
64
  for (const line of outputLines)
59
65
  lines.push('output: ' + line);
60
66
  if (tools.length)
@@ -88,6 +88,55 @@ export function createRunProvider(deps) {
88
88
  ? { ...(p.tools?.length ? { allow: p.tools } : {}), ...(p.toolsDeny?.length ? { deny: p.toolsDeny } : {}) }
89
89
  : null;
90
90
  const resolved = toolFilter === undefined ? legacyFilter : toolFilter;
91
+ // provider 与 model 是模型路由的两半:DSH 的 resolveModel(provider, model) 不做
92
+ // `provider/model` 字符串拆分,只改 model 会落在**主会话的 provider** 上——跨来源
93
+ // 指定模型(如 sensenova 的 sensenova-6.8-flash-lite)必须两个键一起给。
94
+ // 2026-09-23 加 `reasoningEffort`:条件是"三个键任一有值"—— 只设强度(不改模型)也得
95
+ // 把这个对象发出去,否则那一格永远不生效。官方合并语义(`dsh-subagent/lib/index.js:471-483`)
96
+ // 是"先继承主会话的三项、再用这里的覆盖",所以只给 reasoningEffort 时 provider/model
97
+ // 照旧继承;反过来,改了 provider/model 而没给 reasoningEffort 时,**继承来的那一档会被
98
+ // 删掉**(回落到该模型自己的默认)—— 那不是 bug,是官方的显式语义。
99
+ // 两条通道(一次性 / 可继续)共用这一段,所以提出来只写一次。
100
+ const routeOptions = (p.provider || p.model || p.reasoningEffort)
101
+ ? {
102
+ agentOptions: {
103
+ ...(p.provider ? { provider: p.provider } : {}),
104
+ ...(p.model ? { model: p.model } : {}),
105
+ // 类型是官方的品牌串 `ReasoningEffortId`;我们的值本来就是 adapter 自己给的
106
+ // opaque id(界面从 `resolveModelInfo` 的清单里选出来的),所以这里只做归一、
107
+ // 不重新校验 —— 不支持的档位由官方在 provider I/O 之前拒。
108
+ ...(p.reasoningEffort ? { reasoningEffort: p.reasoningEffort } : {}),
109
+ },
110
+ }
111
+ : {};
112
+ // ── 允许追问通道(人设 `continuable: true`,0.19.0)────────────────────────────
113
+ // 与一次性走的是**两条官方通道**:`startContinuable` 建持久子会话,在**收件箱受理时**
114
+ // 即兑现(不是跑完),返回稳定 `childId`,子代理在后台继续跑。追问不需要本插件再出工具
115
+ // —— 宿主自带全局 `send_message` / `interrupt_agent`(`dsh-tool-subagent-control`),
116
+ // 它们对任何可继续子级都生效,与创建它的工具无关。这里只负责"把它建起来"。
117
+ //
118
+ // 两条官方语义(`dsh-subagent/lib/index.js:599-654`)决定了回执只能写成"已启动 + id":
119
+ // · 子代理启动时被追加一句返回指引,要求它**自己**用 send_message 把结果发给父代理
120
+ // —— 父代理不会自动收到它的转录、工具输出或思考;
121
+ // · 结算时父代理还会收到一条 `subagent-settled` 通知(含收尾正文)。
122
+ // 所以这条通道的产出是**异步**到达的,不是本函数的返回值。
123
+ if (p.continuable === true) {
124
+ const started = await runtime.startContinuable({
125
+ provider: kind,
126
+ label: p.name,
127
+ // `ContinuableStartSpec.request` 是 `Omit<SubagentStartRequest,'label'|'signal'|'outputSchema'>`:
128
+ // label 提到 spec 顶层、signal 由 spec 自己带(官方接管准备期的取消),两者都不进 request。
129
+ request: {
130
+ parent: parentAgent,
131
+ prompt: [{ type: 'text', text: task }],
132
+ persona: renderPersonaPrompt(p),
133
+ ...(resolved === null ? {} : { toolFilter: resolved }),
134
+ ...routeOptions,
135
+ },
136
+ signal: signal ?? new AbortController().signal,
137
+ });
138
+ return { text: '', runId: String(started?.childId ?? ''), stopReason: 'started', continuable: true };
139
+ }
91
140
  // 官方签名:start(name, request) —— name = ctx.subagents 上的 provider 注册名。
92
141
  const run = await runtime.start(kind, {
93
142
  label: p.name,
@@ -107,26 +156,8 @@ export function createRunProvider(deps) {
107
156
  // "目录出现在哪"。传了它会让我们的工具比官方严(1 vs 3),且会要求 provider 具备
108
157
  // `depthLimit` capability(不传就没这个依赖)。让 provider 用它自己的默认,与官方
109
158
  // 工具的能力保持一致。
110
- // provider 与 model 是模型路由的两半:DSH 的 resolveModel(provider, model) 不做
111
- // `provider/model` 字符串拆分,只改 model 会落在**主会话的 provider** 上——跨来源
112
- // 指定模型(如 sensenova 的 sensenova-6.8-flash-lite)必须两个键一起给。
113
- // 2026-09-23 加 `reasoningEffort`:条件是"三个键任一有值"—— 只设强度(不改模型)也得
114
- // 把这个对象发出去,否则那一格永远不生效。官方合并语义(`dsh-subagent/lib/index.js:471-483`)
115
- // 是"先继承主会话的三项、再用这里的覆盖",所以只给 reasoningEffort 时 provider/model
116
- // 照旧继承;反过来,改了 provider/model 而没给 reasoningEffort 时,**继承来的那一档会被
117
- // 删掉**(回落到该模型自己的默认)—— 那不是 bug,是官方的显式语义。
118
- ...(p.provider || p.model || p.reasoningEffort
119
- ? {
120
- agentOptions: {
121
- ...(p.provider ? { provider: p.provider } : {}),
122
- ...(p.model ? { model: p.model } : {}),
123
- // 类型是官方的品牌串 `ReasoningEffortId`;我们的值本来就是 adapter 自己给的
124
- // opaque id(界面从 `resolveModelInfo` 的清单里选出来的),所以这里只做归一、
125
- // 不重新校验 —— 不支持的档位由官方在 provider I/O 之前拒。
126
- ...(p.reasoningEffort ? { reasoningEffort: p.reasoningEffort } : {}),
127
- },
128
- }
129
- : {}),
159
+ // provider / model / reasoningEffort 的合并语义见上方 `routeOptions` 的注释。
160
+ ...routeOptions,
130
161
  });
131
162
  try {
132
163
  const result = await run.result; // 官方契约:child 级失败不 reject(stopReason 体现)
@@ -147,7 +178,10 @@ export function createRunProvider(deps) {
147
178
  await run.dispose().catch(() => { }); // 官方前台语义:collection 后即弃
148
179
  }
149
180
  }
150
- // v1 串行:同一时刻至多一个子代理运行(设计 §3.2)。
181
+ // v1 串行:同一时刻至多一次**启动**在途(设计 §3.2)。
182
+ // ⚠️ 0.19.0 起这条不再等于"至多一个子代理在跑":允许追问(`continuable`)的子代理在
183
+ // `startContinuable` 返回后仍在后台运行,而这里只串行化"启动"这一步。这是官方通道的
184
+ // 固有语义(后台子级本来就允许并存),不是本插件放宽了限制。
151
185
  let chain = Promise.resolve();
152
186
  const runSerial = (parentAgent, p, task, signal, toolFilter, inherit) => {
153
187
  const start = () => runOnce(parentAgent, p, task, signal, toolFilter, inherit);