dsh-plugin-tool-management 0.18.5 → 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.
@@ -67,6 +67,19 @@ export const DOMAIN_FRAME = {
67
67
  supersede: SUPERSEDE_NOTE,
68
68
  },
69
69
  };
70
+ /**
71
+ * `how` 行里**点名过的工具**(六个域里只有 skills / subagents 两个会点名)。
72
+ *
73
+ * 为什么要有这份名单:一条 `how` 行是否成立,取决于它点名的工具在**这个 agent 手里**还在不在。
74
+ * 名单放在这里而不是调用方 —— 与上面那两条 `how` 相邻,改一处不会漏另一处。
75
+ *
76
+ * "不在手里"有两条路,都要算:
77
+ * ① 兼容页「模型工具表」把它关掉(用户全局开关,名单在 index.ts 的 `hiddenTools` 里);
78
+ * ② **人设的工具限制**把它砍掉(`decideToolFilter` → 官方 `tools.restrict`,只作用于那份
79
+ * 人设启动的子代理)。②此前没人管:目录照注、`how` 行照样点名一个模型手里没有的工具,
80
+ * 等于诱导它去调一个不存在的名字(2026-10-07 修)。
81
+ */
82
+ export const HOW_NAMED_TOOLS = ['skill_manager_read', 'subagent_manager_run'];
70
83
  /** 域声明里没登记的 key(理论上到不了这里):给一个不出错的通用框架。 */
71
84
  export const fallbackFrame = (label) => ({
72
85
  title: `本机的${label}`,
@@ -20,9 +20,10 @@ export function normalizeInjectSettings(raw) {
20
20
  * 规则(设计 2026-09-16;目录注入深度 2026-09-17):
21
21
  * - 域开关关掉的、文本为空的,都不进;
22
22
  * - **压制型预设**且没开「仍然注入」→ 一个都不进(跟随预设);
23
- * - 域声明 `applicableTo` 说不成立的域不进(当前只有人设目录:会话深度超出了人设的
24
- * `catalogDepth`,默认 1 = 只在顶层注入。**这不是"子会话不能委派"** —— 委派由官方
25
- * 决定,见 service.ts 里 `catalogDepth` 的注释);
23
+ * - 域声明 `applicableTo` 说不成立的域不进(**深度判据逐域写**:场景与记忆只在顶层
24
+ * —— 它们说的是父会话的处境;人设目录则看会话深度有没有超出人设的 `catalogDepth`,
25
+ * 默认 1 = 只在顶层注入。**这都不是"子会话不能委派"** —— 委派由官方决定,
26
+ * 见 service.ts 里 `catalogDepth` 的注释);
26
27
  * - 有官方载体的两个域(提示词 / 技能目录):预设挂得到官方那条行时不进
27
28
  * —— 官方自己会送,再注入一遍只会重复;预设事实读不到(`undefined`)时按"已承载"处理
28
29
  * (宁可与现状一致,也不制造重复);
@@ -80,7 +80,7 @@ import { kindOfMessage, LIVE_KINDS, newestDomainTexts, officialKindsToSuppress,
80
80
  // subagents/{catalog,service}.ts 与契约测试都按这个路径引)。
81
81
  export { DEFAULT_INJECT_SETTINGS, DOMAIN_TOOL_PREFIX, INJECT_DOMAIN_KEYS, INJECT_KIND_OF, domainOfTool, isSubagentSession, subagentDepthOf, } from './context-inject-contract.js';
82
82
  export { explainInjections, normalizeInjectSettings, selectInjections, } from './context-inject-select.js';
83
- export { clearedDomainText, escapeFrameBody, renderDomainText } from './context-inject-frame.js';
83
+ export { clearedDomainText, escapeFrameBody, HOW_NAMED_TOOLS, renderDomainText } from './context-inject-frame.js';
84
84
  export { officialKindsToSuppress } from './context-inject-surface.js';
85
85
  // B4:关域拦截的**顺序自检**。官方那两条注入行与本插件同挂在这条瀑布上,拦截成立的剩余前提是
86
86
  // "本插件排在它们上游"(本插件用 `{ prepend: true }` 把自己钉在钩子表最前,所以真正还依赖的
@@ -370,8 +370,9 @@ export function createContextInjector(deps) {
370
370
  const published = new Set();
371
371
  // 域声明按 key 索引:来源文件(`files`)只在真要发消息时取,所以要能从这里回查声明。
372
372
  const domainOf = new Map(domains.map((domain) => [domain.key, domain]));
373
- // 工具表开关本步读一次(同步快照):`how` 行里点名工具的那几句据此换话术。
374
- const hiddenNow = deps.hiddenTools !== undefined ? deps.hiddenTools() : null;
373
+ // `how` 行点名工具的那几句据本步的快照换话术。按 agent 取:全局开关之外,
374
+ // 人设的工具限制也会让工具不在手里,而那份名单逐 agent 不同。
375
+ const hiddenNow = deps.hiddenTools !== undefined ? deps.hiddenTools(agent) : null;
375
376
  const toolHidden = hiddenNow === null ? () => false : (name) => hiddenNow.has(name);
376
377
  for (const section of sections) {
377
378
  published.add(section.key);
package/lib/index.js CHANGED
@@ -30,7 +30,7 @@ import { runStateDoctor } from './ops/state-doctor.js';
30
30
  import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
31
31
  import { createSubagentCatalog } from './subagents/catalog.js';
32
32
  import { createSkillCatalog } from './skills/catalog.js';
33
- import { createContextInjector, subagentDepthOf, } from './context-inject.js';
33
+ import { createContextInjector, HOW_NAMED_TOOLS, subagentDepthOf, } from './context-inject.js';
34
34
  import { isApprovalNever } from './approval-policy.js';
35
35
  import { DEFAULT_HIDDEN_TOOLS, FACTORY_TOOL_TABLE_PRESET, normalizeToolTableSettings, } from './tools/table.js';
36
36
  import { createToolTableSwitch } from './tool-table-switch.js';
@@ -624,6 +624,35 @@ export default {
624
624
  }
625
625
  return out;
626
626
  };
627
+ /**
628
+ * `how` 行点名过的工具里,**这个 agent 手里没有**的那些(注入器每步同步问一次)。
629
+ *
630
+ * 两条路并起来,缺一条框架文本就会说谎:
631
+ * ① `toolTableHidden()` —— 兼容页「模型工具表」的全局开关。**必须留着**:它读的是设置
632
+ * 本身,与"限制层装没装上"无关(可见性的重放有 300ms 去抖,只问可见性会有一个窗口漏报)。
633
+ * ② `tools.get(name, agent)` —— 拿 agent 当 scope 问"它解析得到这个名字吗",与
634
+ * `carrierHiddenToolsFor` 同一套官方写法。这一条覆盖**人设的工具限制**:
635
+ * `decideToolFilter` 算出的 allow/deny 由官方在子代理启动时 `tools.restrict` 装到那个
636
+ * scope 上(`dsh-subagent/lib/index.js:522`),全局开关表里看不到它 —— 于是人设砍掉
637
+ * `skill_manager_read` 之后,技能目录照注、`how` 行照样点名一个模型手里没有的工具。
638
+ * 拿不到 agent 时跳过:`get(name, undefined)` 问的是**全局视图**,会得出相反结论。
639
+ */
640
+ const howHiddenToolsFor = (agent) => {
641
+ const hidden = new Set(toolTableHidden());
642
+ if (agent === null || (typeof agent !== 'object' && typeof agent !== 'function'))
643
+ return hidden;
644
+ const scope = agent;
645
+ for (const name of HOW_NAMED_TOOLS) {
646
+ if (hidden.has(name))
647
+ continue;
648
+ try {
649
+ if (tools.get(name, scope) === undefined)
650
+ hidden.add(name);
651
+ }
652
+ catch { /* 读不到 = 按还在手里算 */ }
653
+ }
654
+ return hidden;
655
+ };
627
656
  // ---------- 注入通道(场景 / 记忆 / MCP / 技能 / 子智能体 / 提示词,各一条消息)----------
628
657
  // 这些文本以前是 systemPrompt 段(persona complete 会整段压掉)。现在改走官方的
629
658
  // 「每步注入一条合成消息」通道(skill-catalog / AGENTS.md / 时间上下文同款):
@@ -673,23 +702,23 @@ export default {
673
702
  // 子智能体 → 提示词。form 是宿主语义轴:记忆是「当前状态」(snapshot,后发取代先发),
674
703
  // 三个目录是 catalog,提示词是 instructions(与官方 AGENTS.md 那条行同一形态)。
675
704
  //
676
- // `applicableTo`(2026-09-17 方案 A):子智能体域按"**目录该不该注入到这么深的
677
- // 会话**"(人设的 `catalogDepth`,默认 1 = 只在顶层注入)判断,**不是**"能不能
678
- // 委派"。委派可行性由官方决定:`dsh-tool-subagent` 默认 `maxDepth: 3`,provider
679
- // 只在传了该值时才校验,所以子代理本来就能继续嵌套(用户实测确认)。此前判据叫
680
- // "还有没有委派预算"、语义是"还能不能委派",那是错的 —— 详见 service.ts 里
681
- // `catalogDepth` 字段的注释。目录正文也按同一个判据过滤(`text(agent)`),两处同源
682
- // 所以不会分叉。
705
+ // 深度判据(`applicableTo`)**逐域写在域声明上**,三个域各一条,语义都**不是**"能不能
706
+ // 委派"。委派可行性由官方决定:`dsh-tool-subagent` 默认 `maxDepth: 3`,provider 只在
707
+ // 传了该值时才校验,所以子代理本来就能继续嵌套(用户实测确认)。此前判据叫"还有没有
708
+ // 委派预算"、语义是"还能不能委派",那是错的 —— 详见 service.ts 里 `catalogDepth` 的注释。
709
+ // · 场景与记忆:**恒为"只在顶层"**。这两段说的是**父会话的处境**(此刻在哪个场景、
710
+ // 这个场景下攒了什么),子代理要什么由父代理写进委派说明;记忆本体又是几 KB 的
711
+ // 常驻文本,子代理真有需要可以自己用 `memory_manager_*` 取。放它们进子会话,
712
+ // 子代理的上下文就不再收敛为「角色 + 任务」。(0.19.0 中途有一版把这两条放开到
713
+ // 所有深度,已回退。)
714
+ // · 子智能体目录:人设的 `catalogDepth`(默认 1 = 只在顶层)—— 它问的是"**目录该不该
715
+ // 注入到这么深的会话**",与"这个域要不要注入"是两件事。目录正文也按同一判据过滤
716
+ // (`text(agent)`),两处同源所以不会分叉。
717
+ // · MCP / 技能 / 提示词:不分深度。
683
718
  //
684
- // 场景与记忆(2026-09-17 用户裁定;2026-09-23 拆成两个域):**都只在顶层注入**。
685
- // 记忆是"父会话的现场",不是子代理完成任务所需的事实 —— 塞进一次性子会话只会与
686
- // 角色定义争注意力(实测:子代理跑审查时,上下文里同时躺着人设与整份记忆)。
687
- // 场景段同理:它说的是"父会话现在处在哪个模式",而子代理根本没有"模式"可言。
688
- // 子代理手里有 `memory_manager_list/read` 与 `scene_manager_save`,需要什么自己取;
689
- // 父代理上下文里也有,相关事实应当由它写进 `task`(子代理的上下文 = 角色 + 任务)。
690
- // 其余三域对任何深度都成立:提示词是用户规则(本插件的立身之本就是"覆盖到子代理")、
691
- // 技能目录与 MCP 状态是"操作这台机器所需的事实"(子代理手里就有 `skill` / `mcp__*`
692
- // 工具,不知道清单就只能瞎调)。
719
+ // 同一张 `domains` 表管着六个域的开关,没有按会话分流的第二张表 —— 那张
720
+ // `subagentDomains` 已删:人设身份在官方机制里取不到(descriptor 是字段白名单、
721
+ // 冷恢复只还原 provider/model/effort)。
693
722
  domains: () => [
694
723
  // 场景(**框架**):启用的场景 + 场景说明(用户的约定)。排第一 —— 先让模型知道
695
724
  // "现在在哪个场景、这个场景的约定是什么",再读下面的记忆条目。
@@ -710,7 +739,7 @@ export default {
710
739
  ],
711
740
  settings: () => injectSettingsSync(),
712
741
  // 工具表开关:`how` 行里点名工具的那几句要跟着它换话术(关掉的不点名)。
713
- hiddenTools: toolTableHidden,
742
+ hiddenTools: howHiddenToolsFor,
714
743
  factsFor: (agent) => candidates.presetFactsForAgent(agent),
715
744
  // 令牌门禁(2026-09-19,用户裁定「宿主侧硬拦截」):令牌**在生效**(`TOKEN !== ''`,
716
745
  // 关掉或没配都是空串)而本次启动还没有人验过 ⇒ 这一步不放行,宿主把 turn 收成
@@ -1399,6 +1428,11 @@ export default {
1399
1428
  // 实现在 ./scenes/candidates.ts(2026-09-19 从本闭包 10 段抽出,约 260 行)。
1400
1429
  // 放在这里而不是更早:它要 mcp(读停用表)与 toolKeyParts(416 行的 const),
1401
1430
  // 而唯一在更早处引用它的地方(注入 deps 的 factsFor)是箭头函数,调用发生在之后。
1431
+ // 本插件注册过的工具名。**唯一**可靠的"插件工具"判据:官方没有工具级归属接口
1432
+ // (`ToolSchema` 无归属字段、`ToolRuntime` 公开面无来源、`tools/change` 无载荷),
1433
+ // 但自己注册了什么自己当然知道。登记点在下面 `toolDeps.register`(2115 行那个漏斗,
1434
+ // 六个域的构建器全部经它),所以不可能漂移 —— 新增工具自动进集合,不用维护前缀表。
1435
+ const selfToolNames = new Set();
1402
1436
  const candidates = createCandidates({
1403
1437
  get: (name) => ctx.get(name),
1404
1438
  tools,
@@ -1406,6 +1440,9 @@ export default {
1406
1440
  skillsService,
1407
1441
  memoriesService,
1408
1442
  toolKeyParts,
1443
+ // 传 getter 而不是快照:登记发生在 apply 过程中(本行之后 600 余行),
1444
+ // 而这个工厂现在就要构造完。
1445
+ selfToolNames: () => selfToolNames,
1409
1446
  });
1410
1447
  // 提示词预设域的 op 表。单独提出来是为了让**模型工具**能复用同一份判定:
1411
1448
  // `prompt_manager_list` 以前直调 `promptsService.list()`,于是它报的「生效中」是
@@ -2089,7 +2126,11 @@ export default {
2089
2126
  defineTool,
2090
2127
  // 量体积只认**注册成功**的那些(注册失败的域工具本来就不在模型工具表里)——
2091
2128
  // 兼容页「模型工具表」块的分组与 ≈token 都取自这里,那是这份设置唯一的数字来源。
2092
- register: (def) => { recordToolSize(def); tools.register(def); },
2129
+ //
2130
+ // 同一行里登记 `selfToolNames`:六个域的构建器全部经这个漏斗,所以它就是"本插件注册了
2131
+ // 哪些工具"的权威清单。`tools.register` 抛(重名 / 保留名)时不登记 —— 那份不在表里,
2132
+ // 也不该出现在「工具限制」的候选里。
2133
+ register: (def) => { recordToolSize(def); tools.register(def); selfToolNames.add(String(def.name)); },
2093
2134
  lockedSceneGuard,
2094
2135
  syncSwitchToScene,
2095
2136
  // 错误句里点名工具前要先问一句(判据与两个目录的 `listToolVisible` 同一份缓存)。
@@ -6,7 +6,7 @@ export function buildCandidateOps(deps) {
6
6
  return {
7
7
  // 人设表单的两个候选源之一(只读,均为「按需拉取」——不进 scene-inventory,避免每次开
8
8
  // 档案弹窗都枚举预设)。
9
- // preset-tools:全体 Agent 预设工具名并集 + 各工具所属预设 + 当前会话是否可见。
9
+ // preset-tools:一行一个 Agent 预设,各带自己的工具清单与归属组(0.19.0 起不再返回全体并集)。
10
10
  // `unavailable` = 本插件自己没注册上的工具:工具列表接口是唯一按名字枚举工具的地方,
11
11
  // 注册失败只能在这里被如实回答。
12
12
  'preset-tools': async () => ({
@@ -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` 的注释。
@@ -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`(标题 + 加粗的动作句 + 工具名行)。