dsh-plugin-tool-management 0.17.0 → 0.18.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +18 -3
  3. package/README_EN.md +18 -3
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +16 -0
  21. package/lib/client.js +1573 -133
  22. package/lib/compat/capability-specs.js +321 -0
  23. package/lib/compat/host-probe.js +261 -0
  24. package/lib/compat/host-roots.js +168 -0
  25. package/lib/compat/probe-types.js +6 -0
  26. package/lib/compat/probe.js +9 -720
  27. package/lib/context-inject-contract.js +114 -0
  28. package/lib/context-inject-frame.js +147 -0
  29. package/lib/context-inject-select.js +127 -0
  30. package/lib/context-inject-surface.js +97 -0
  31. package/lib/context-inject.js +10 -478
  32. package/lib/http-api.js +229 -0
  33. package/lib/hub.js +2 -0
  34. package/lib/index.js +134 -836
  35. package/lib/mcp/inspect-probe.js +199 -0
  36. package/lib/mcp/manager.js +30 -548
  37. package/lib/mcp/tool-visibility.js +388 -0
  38. package/lib/memories/archive-engine.js +142 -5
  39. package/lib/memories/archive.js +10 -1
  40. package/lib/memories/discovery.js +261 -0
  41. package/lib/memories/export-plan.js +42 -0
  42. package/lib/memories/memories-types.js +1 -0
  43. package/lib/memories/service.js +4 -38
  44. package/lib/memories/snapshot.js +10 -252
  45. package/lib/op-registry.js +21 -0
  46. package/lib/ops/compat.js +11 -0
  47. package/lib/ops/quick-prompts.js +26 -0
  48. package/lib/ops/scene-records.js +40 -0
  49. package/lib/ops/scene-sync.js +71 -1
  50. package/lib/ops/sessions.js +13 -9
  51. package/lib/ops/snapshot.js +18 -12
  52. package/lib/prompts/quick-service.js +211 -0
  53. package/lib/scene-linkage.js +184 -0
  54. package/lib/sessions/archive-schemas.js +121 -0
  55. package/lib/sessions/path-key.js +46 -0
  56. package/lib/sessions/workspace.js +5 -163
  57. package/lib/settings-sidecars.js +143 -0
  58. package/lib/skills/core-types.js +8 -0
  59. package/lib/skills/core-util.js +81 -0
  60. package/lib/skills/core.js +56 -2326
  61. package/lib/skills/dir-watch.js +71 -0
  62. package/lib/skills/frontmatter.js +96 -0
  63. package/lib/skills/homes.js +14 -0
  64. package/lib/skills/import-pipeline.js +231 -0
  65. package/lib/skills/manager-state.js +800 -0
  66. package/lib/skills/official.js +269 -0
  67. package/lib/skills/provider-registration.js +89 -0
  68. package/lib/skills/service.js +5 -240
  69. package/lib/skills/skill-roots.js +287 -0
  70. package/lib/skills/skill-trash.js +265 -0
  71. package/lib/skills/system-trash.js +97 -0
  72. package/lib/skills/upload-pipeline.js +374 -0
  73. package/lib/slash-settings.js +25 -0
  74. package/lib/subagents/persona-parse.js +312 -0
  75. package/lib/subagents/persona-types.js +5 -0
  76. package/lib/subagents/persona-write.js +93 -0
  77. package/lib/subagents/run-provider.js +159 -0
  78. package/lib/subagents/service.js +14 -638
  79. package/lib/subagents/tool-filter.js +98 -0
  80. package/lib/token-config.js +240 -0
  81. package/lib/tool-table-switch.js +163 -0
  82. package/lib/tools/scene.js +40 -9
  83. package/package.json +3 -2
@@ -0,0 +1,312 @@
1
+ /** 逗号/顿号分隔的名字清单(空串 → undefined)。 */
2
+ export function listOf(value) {
3
+ const text = String(value ?? '').trim();
4
+ if (!text)
5
+ return undefined;
6
+ const list = text.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
7
+ return list.length ? list : undefined;
8
+ }
9
+ /**
10
+ * 解析 `toolsByPreset:` 块 —— 每个 Agent 预设一行:
11
+ *
12
+ * ```yaml
13
+ * toolsByPreset:
14
+ * standard: allow: read, write
15
+ * ptc: deny: workflow
16
+ * ```
17
+ *
18
+ * 缩进即"在块内",回到顶格即块结束(frontmatter 只有这一处嵌套,所以不需要完整
19
+ * YAML 解析器)。模式 id 只做形状校验,认不认得由运行时按当前预设名单判断。
20
+ */
21
+ export function parsePresetToolRules(frontmatter) {
22
+ const rules = {};
23
+ let inBlock = false;
24
+ for (const raw of frontmatter.split(/\r?\n/)) {
25
+ const line = raw.replace(/\s+#.*$/, '');
26
+ if (!inBlock) {
27
+ if (/^toolsbypreset\s*:\s*$/i.test(line.trim()))
28
+ inBlock = true;
29
+ continue;
30
+ }
31
+ if (line.trim() === '')
32
+ continue;
33
+ if (!/^\s/.test(line))
34
+ break;
35
+ const m = /^\s*([A-Za-z0-9._-]+)\s*:\s*(allow|deny)\s*:\s*(.*)$/i.exec(line);
36
+ if (!m)
37
+ continue;
38
+ const names = listOf(m[3]);
39
+ // 空名单 = 不限制 = 不落盘,读到时也直接忽略,免得把"没开"读成"开了但空"。
40
+ if (!names)
41
+ continue;
42
+ rules[m[1]] = { mode: m[2].toLowerCase() === 'deny' ? 'deny' : 'allow', names };
43
+ }
44
+ return Object.keys(rules).length ? rules : undefined;
45
+ }
46
+ /** 行式 frontmatter 解析:只认 description / provider / model / tools / toolsDeny / toolsByPreset / catalogDepth / output。 */
47
+ /**
48
+ * 思考强度档位 id 的归一:单行、去空白、限长。**不校验它是不是合法档位** —— 清单要问 adapter
49
+ * 才拿得到(可能拿不到),而官方本来就会在 provider I/O 之前拒掉不支持的显式值。
50
+ *
51
+ * 为什么必须去换行:frontmatter 是**逐行**解析的(`parsePersona`),值里带换行会把后面半截
52
+ * 变成一行无主文本。限长是防脏值(正常档位 id 都很短)。
53
+ */
54
+ export const REASONING_EFFORT_MAX_LENGTH = 64;
55
+ export function normalizeReasoningEffort(value) {
56
+ return String(value ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, REASONING_EFFORT_MAX_LENGTH);
57
+ }
58
+ export function parsePersona(raw, fallbackName) {
59
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
60
+ const data = {};
61
+ const outputLines = [];
62
+ let body = raw;
63
+ let toolsByPreset;
64
+ if (m) {
65
+ for (const line of m[1].split(/\r?\n/)) {
66
+ const kv = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(line.trim());
67
+ if (!kv)
68
+ continue;
69
+ const key = kv[1].toLowerCase();
70
+ // `output:` 是**可重复**的键(一条要求一行,按顺序收集)。frontmatter 是逐行解析的,
71
+ // 多行契约塞不进一个值里;写成重复键比引入块标量语法简单,也一眼看得懂。
72
+ if (key === 'output') {
73
+ if (kv[2].trim() !== '')
74
+ outputLines.push(kv[2].trim());
75
+ continue;
76
+ }
77
+ data[key] = kv[2].trim();
78
+ }
79
+ toolsByPreset = parsePresetToolRules(m[1]);
80
+ body = raw.slice(m[0].length);
81
+ }
82
+ const tools = listOf(data.tools);
83
+ // 兼容两种写法:`toolsdeny: a, b`(线上格式)与 `toolsDeny: a, b`(键名统一小写后同形)。
84
+ const toolsDeny = listOf(data.toolsdeny);
85
+ const firstLine = body.split(/\r?\n/).find((l) => l.trim())?.trim() ?? '';
86
+ const catalogDepth = parseCatalogDepth(data);
87
+ return {
88
+ name: fallbackName,
89
+ description: data.description || firstLine,
90
+ provider: data.provider || undefined,
91
+ model: data.model || undefined,
92
+ // frontmatter 键统一小写后是 `reasoningeffort`(写出去的是 camelCase)。
93
+ reasoningEffort: normalizeReasoningEffort(data.reasoningeffort) || undefined,
94
+ tools,
95
+ toolsDeny,
96
+ ...(toolsByPreset === undefined ? {} : { toolsByPreset }),
97
+ ...(catalogDepth === undefined ? {} : { catalogDepth }),
98
+ ...(outputLines.length ? { output: outputLines.join('\n') } : {}),
99
+ body: body.trim(),
100
+ path: '',
101
+ };
102
+ }
103
+ /** 零宽空格:用来拆开 `{{`,视觉上不留痕。 */
104
+ export const ZERO_WIDTH = '\u200b';
105
+ /**
106
+ * 拆开正文里的 `{{`,让它不再被宿主当作提示词变量引用。
107
+ *
108
+ * 宿主对 section 文本做**严格**变量插值(dsh-system-prompt 的 `interpolate`):
109
+ * 命中 `{{name}}` 而该变量没注册就抛错,整个子代理启动失败。人设是用户自由文本,
110
+ * 作者写 `{{foo}}` 几乎一定是字面量,却会让委派直接崩掉。这里在两个花括号之间插一个
111
+ * 零宽空格:模型看到的仍是 `{{foo}}`,宿主再也找不到 `{{`。**只拆开,不删除**——
112
+ * 用户写下的每一个可见字符都保留。
113
+ */
114
+ export function neutralizePromptVariables(text) {
115
+ // 在"后面还是左花括号"的 `{` 之后插入零宽空格,把 `{{` 拆成 `{<ZWSP>{`。
116
+ // 用前瞻断言而不是 `replace(/\{\{/g, …)`:后者的替换串**尾字符也是 `{`**,
117
+ // 遇到 `{{{` 会与被替换掉的首括号后面的原字符重新拼出 `{{`(实测踩到)。
118
+ // 前瞻写法一次扫描就够,连续多少个左括号都处理干净。
119
+ return text.replace(/\{(?=\{)/g, `{${ZERO_WIDTH}`);
120
+ }
121
+ /**
122
+ * 这个人设主要用中文写的?启发式,判错的代价只是框的语言不搭。
123
+ *
124
+ * 判定范围是**人设整体**(名字 + 描述 + 正文),不只是正文:正文可能是占位符、
125
+ * 代码片段或纯英文标识(实测有人的正文是 `123`),只看正文会把一个中文人设
126
+ * 判成英文、配上英文框。名字与描述是作者写的,同样能说明语言。
127
+ */
128
+ export function isChinesePersona(name, description, body) {
129
+ const sample = `${name}\n${description}\n${body}`;
130
+ const cjk = (sample.match(/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g) || []).length;
131
+ if (cjk === 0)
132
+ return false;
133
+ const latin = (sample.match(/[A-Za-z]/g) || []).length;
134
+ // 汉字信息密度高于字母,1 个汉字按 2 个字母折算,避免"中文正文夹少量英文术语"被判成英文。
135
+ return cjk * 2 >= latin;
136
+ }
137
+ /**
138
+ * 人设 → 子代理系统提示词里那一段的**成品文本**(2026-09-17)。
139
+ *
140
+ * 为什么需要这层框:宿主给子代理的 persona 只占一个槽位 —— `deployment:persona-prefix`,
141
+ * section order 0,位置紧贴 `harness:identity`(order -1000,那句 "You are an AI agent
142
+ * powered by DeepSeek Harness.")。而宿主自己的 `personaPrefix` 配置是**部署级短前缀**,
143
+ * 它默认由部署方承担框架。我们把整篇人设正文直接填进这个槽,等于交出一段无标题、无归属、
144
+ * 无授权的裸文本:模型读到的是"身份句后面又跟了两句话",而不是"我被指定为一个角色"。
145
+ * 用户写的 `必须…` 因此只是背景信息,没有上位效力。
146
+ *
147
+ * 两份参考实现都靠**框**解决同一件事:Claude Code 把代理提示词放在系统提示词**首位**,
148
+ * 追加内容一律带 `#`/`##` 标题与指令段(`AgentTool/agentMemory.ts`、`memdir/memdir.ts`);
149
+ * Codex 在指令文件交界处插入来源标记 `--- project-doc ---`,让模型知道权威边界从哪开始
150
+ * (`core/src/agents_md.rs`)。两者都没有"把用户正文裸拼进系统提示词"这种做法。
151
+ *
152
+ * 框里四样东西各有分工,缺一样就退化成原来的裸文本:
153
+ * - `# 角色:名` —— 结构信号(markdown 标题是模型最强的分段线索)+ 可引用的名字;
154
+ * - 归属 + 授权一行 —— "由调用方指定、与默认倾向冲突时以它为准",给正文上位效力;
155
+ * 2026-09-17 补了半句"任务说要什么,怎么做以它为准":不写这句,模型会把 `task`
156
+ * (父代理按自己的框架写的具体指令)读成人设的上级,人设只剩风格提示的分量;
157
+ * - 边界一句 —— 角色决定**怎么做**,不改变**能做什么**。这不是装饰:官方
158
+ * `subagent:delegation` 运行时上下文已经声明"权限在启动时固定",两处不呼应的话,
159
+ * 写着"你可以随意写文件"的人设会读起来像与工具限制矛盾;
160
+ * - `## 角色定义` 标题 + 正文 —— 明确起止,把用户文本围起来。
161
+ *
162
+ * 语言跟随人设整体(名字 + 描述 + 正文):中文人设配中文框,英文配英文。
163
+ *
164
+ * 框里**不列 `description`**(2026-09-17 用户裁定,此前列):那个字段的用途是**给调用方
165
+ * 选用**(本机那份写的是"当用户提到审查或使用审查子智能体时使用"),对已经在执行的人设
166
+ * 子代理是错位信息 —— 可能被读成"满足某个条件才按这个角色"的前置条件。要给人设子代理
167
+ * 看的简介属于正文(`description` 仍照常参与语言判定与目录展示)。
168
+ *
169
+ * 正文为空时退化成只给一个名字 —— 空框比没有框更糟。
170
+ */
171
+ export function renderPersonaPrompt(persona) {
172
+ // 名字与正文/输出过同一道中和:名字原样进框时,宿主对 section 文本的严格插值会把
173
+ // `{{…}}` 当未注册变量抛错,委派直接硬失败 —— 0.9.5 的中和只盖了正文与输出,漏了名字
174
+ // (validPersonaName 不挡花括号,手写 frontmatter 造得出这种名字)。
175
+ const name = neutralizePromptVariables(String(persona.name || '').trim()) || '(unnamed)';
176
+ const raw = String(persona.body ?? '');
177
+ const body = neutralizePromptVariables(raw.trim());
178
+ if (body === '')
179
+ return name;
180
+ const rawDescription = String(persona.description ?? '').trim();
181
+ const output = neutralizePromptVariables(String(persona.output ?? '').trim());
182
+ const lines = isChinesePersona(name, rawDescription, raw)
183
+ ? [
184
+ `# 角色:${name}`,
185
+ '',
186
+ `你正在以「${name}」的身份执行本次委派任务。以下角色定义由调用方指定,是你本次运行的固定行为准则:与你的默认倾向冲突时,以它为准;任务说要什么,怎么做以它为准。它决定你如何工作,不改变你的权限范围。`,
187
+ '',
188
+ '## 角色定义',
189
+ '',
190
+ body,
191
+ ...(output === '' ? [] : ['', '## 输出要求(硬性)', '', output]),
192
+ ]
193
+ : [
194
+ `# Persona: ${name}`,
195
+ '',
196
+ `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
+ '',
198
+ '## Persona',
199
+ '',
200
+ body,
201
+ ...(output === '' ? [] : ['', '## Output requirements (hard)', '', output]),
202
+ ];
203
+ return lines.join('\n');
204
+ }
205
+ /** 一个人设没写 `catalogDepth` 时的目录注入深度:1 —— 只在顶层注入目录。 */
206
+ export const DEFAULT_PERSONA_CATALOG_DEPTH = 1;
207
+ /**
208
+ * 「不限制嵌套」的目录注入深度(界面下拉的第四档,前端同值见 client.js 的
209
+ * `CATALOG_DEPTH_UNLIMITED`)。
210
+ *
211
+ * 取值远大于宿主能嵌套到的深度(官方 `dsh-tool-subagent` 默认 `maxDepth: 3`),所以判据
212
+ * `深度 < catalogDepth` 在任何可达的会话里都成立 —— 判据本身不必为它加特例分支。
213
+ */
214
+ export const UNLIMITED_PERSONA_CATALOG_DEPTH = 99;
215
+ /**
216
+ * 这个人设的目录注入深度(非法值一律退回默认,**默认从不放宽**)。
217
+ *
218
+ * 为什么非法值退回默认而不是报错:`catalogDepth` 是 frontmatter 里手写的字段,写错一个
219
+ * 数字不该让整个人设不可用;而"退回默认"是安全方向 —— 默认只注入到顶层,噪声最小。
220
+ */
221
+ export function catalogDepthOf(persona) {
222
+ const value = persona?.catalogDepth;
223
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
224
+ return DEFAULT_PERSONA_CATALOG_DEPTH;
225
+ return value;
226
+ }
227
+ /**
228
+ * 深度为 `depth` 的会话里,该不该注入这个人设所在的目录?
229
+ *
230
+ * 判据是 `depth < 目录注入深度`。**这个函数是三处口径的唯一来源** —— 目录要不要列出、
231
+ * 域该不该注入、实况报什么状态,全都问它。各写一份必然分叉。
232
+ *
233
+ * ⚠️ 它回答的**不是**"能不能委派"(那由官方决定,且默认能嵌套到 3 层)。这个区分是
234
+ * 2026-09-17 用户实测后纠正的:此前函数名是 `canDelegateTo`,把"目录可见性"当成了
235
+ * "委派可行性",于是同一个子会话里官方工具能委派、我们的工具被自己的守卫拦下。
236
+ */
237
+ export function catalogInjectedAt(persona, depth) {
238
+ const from = Number.isSafeInteger(depth) && depth >= 0 ? depth : 0;
239
+ return from < catalogDepthOf(persona);
240
+ }
241
+ /** frontmatter 里目录注入深度的几种写法都认(含旧的 `maxDepth`,见下)。 */
242
+ export function parseCatalogDepth(data) {
243
+ // 旧键 `maxDepth` 也读:0.9.5 定稿前这个字段叫过那个名字,且当时语义是"递归上限"。
244
+ // 读它只是为了不让手写过旧键的文件静默失效;**写回时一律写新键**(见 serializePersona)。
245
+ const raw = data.catalogdepth ?? data['catalog-depth'] ?? data.catalog_depth ?? data.maxdepth ?? data['max-depth'] ?? data.max_depth;
246
+ if (raw === undefined || raw.trim() === '')
247
+ return undefined;
248
+ const value = Number(raw.trim());
249
+ if (!Number.isSafeInteger(value) || value < 0)
250
+ return undefined;
251
+ return value;
252
+ }
253
+ export const RESULT_MAX = 16 * 1024;
254
+ /**
255
+ * 从内容块数组里按 `type` 取文本并拼接 —— 官方 `finalText` 的口径
256
+ * (`dsh-subagent/lib/index.js:2658`:`blocks.filter(block => block.type === "text")`;
257
+ * `dsh-tool-subagent` 的 `outputValueText`、`withDiagnosticAndPartialText` 同样如此)。
258
+ *
259
+ * **必须按 `type` 过滤,不能只判有没有 `text` 字段**:`ContentBlock` 里 `TextBlock` 与
260
+ * `ReasoningBlock` 的结构完全相同(都是 `{ type, text: string }`,见 dsh-llm 的
261
+ * `types.d.ts:39/44`),只判字段会把**思考**当成正文拼进去。本机 298 条子代理终局消息里
262
+ * 294 条受影响:211 条思考与正文并存(返回结果的开头变成「我做了 1、2、3」这类自我校验,
263
+ * 正文被顶到后面),83 条压根没有正文。
264
+ *
265
+ * `typeof b.text === 'string'` 这层是防 provider 给出畸形块(官方工具也这么防)。
266
+ */
267
+ export function textOfBlocks(blocks, type) {
268
+ if (!Array.isArray(blocks))
269
+ return '';
270
+ return blocks
271
+ .filter((b) => b !== null && typeof b === 'object' && b.type === type && typeof b.text === 'string')
272
+ .map((b) => b.text)
273
+ .join('')
274
+ .trim();
275
+ }
276
+ /**
277
+ * 没有正文时给调用方的一句话。
278
+ *
279
+ * 要解决的是**误读**:原来一律回 `(子代理无输出)`,而「子代理没干活」和「干了活但没写出
280
+ * 正文」是两件事,报成同一句会让调用方把它当成一次空跑。所以这一句里必须同时说清两件事:
281
+ * ① 它**干了活**(产出了思考)—— 否则会被读成空跑;
282
+ * ② 这次委派**没有拿到可用结果** —— 否则会被当成一份内容读下去。
283
+ *
284
+ * 按收尾原因分档,而不是给一句通用话:实测数据推翻了「它忘了写结论」这个想当然的解释 ——
285
+ * 本机 83 条「没有正文」的终局里**没有一条是 `completed`**(max-tokens 36、error 20、
286
+ * 无收尾记录 22、aborted 5),而有正文那批 215 条里 195 条是 `completed`。官方自己也是
287
+ * 这么分的:`dsh-tool-subagent/lib/index.js:316` 对任何非 `completed` 的收尾**直接抛错**
288
+ * (`stopReasonError` → `withDiagnosticAndPartialText`),只有 `completed` 才当结果返回。
289
+ * 所以这个兜底出现的场合,对策基本都是「拆小任务 / 查诊断 / 换人设」,不是原样再发一次。
290
+ *
291
+ * 分档用的是官方 `stopReasonError` 认的那几个值,出现新值时落回最后那句通用说明。
292
+ *
293
+ * 刻意**不把思考正文塞回来**:那正是这次要修的泄漏,换个标签塞回去等于没修。
294
+ */
295
+ export function emptyResultNote(output, stopReason) {
296
+ // 连思考都没有 → 子代理确实什么都没产出,这时说「没干活」是对的。
297
+ if (textOfBlocks(output, 'reasoning') === '')
298
+ return '(子代理无输出)';
299
+ const head = '子代理没有返回正文 —— 它产出了思考';
300
+ switch (stopReason) {
301
+ case 'max-tokens':
302
+ return `(${head},但写出结论前就用完了 token 上限;需要结果请把任务拆小些再委派。)`;
303
+ case 'error':
304
+ return `(${head},但中途出错了;诊断信息见后。)`;
305
+ case 'aborted':
306
+ return `(${head},但写出结论前被中止了。)`;
307
+ case 'refusal':
308
+ return `(${head},但它拒绝了这项任务。)`;
309
+ default:
310
+ return `(${head},但没有写出结论;可以拆小任务重新委派,或你自己接着做。)`;
311
+ }
312
+ }
@@ -0,0 +1,5 @@
1
+ // 子代理域的公开类型面:人设文档、预设工具规则、工具过滤三态、服务出口。
2
+ //
3
+ // 从 subagents/service.ts 整段搬来,一行未改。类型在编译期擦除,单独一层只为把「形状」
4
+ // 与「实现」分开放 —— service.ts 那 1200 行里,读一个接口要翻过两百行实现。
5
+ export {};
@@ -0,0 +1,93 @@
1
+ // 人设文件的写侧:名字合法性、字符串数组规范化、frontmatter 序列化。
2
+ //
3
+ // 从 subagents/service.ts 整段搬来,一行未改。只写用户填过的键这条纪律留在函数自己的
4
+ // 注释里;名字谓词仍收敛到 ../paths.ts,这里不另写一份。
5
+ import { isValidSegment } from '../paths.js';
6
+ import { DEFAULT_PERSONA_CATALOG_DEPTH, normalizeReasoningEffort } from './persona-parse.js';
7
+ /** 人设名长度上限(字符)。 */
8
+ export const PERSONA_NAME_MAX = 64;
9
+ /**
10
+ * 人设名合法性:谓词收敛到 `../paths.ts`(此前这里与 rules / imports / skills 各写一套,
11
+ * 缺了 Windows 保留设备名与控制字符检查 —— 设备名在 Windows 上创建即失败)。
12
+ * 注意:**不挡花括号**,手写 frontmatter 仍造得出 `{{…}}` 名字,渲染侧照旧做中和
13
+ * (见 renderPersonaPrompt)。
14
+ */
15
+ export function validPersonaName(name) {
16
+ return isValidSegment(name, PERSONA_NAME_MAX);
17
+ }
18
+ /** 字符串数组规范化(非数组/空项都丢掉)。 */
19
+ export function toStringList(value) {
20
+ return Array.isArray(value) ? value.map((x) => String(x).trim()).filter(Boolean) : [];
21
+ }
22
+ /** 人设文件序列化:frontmatter 只写用户填过的键;正文 = 人设提示词。 */
23
+ export function serializePersona(args) {
24
+ const description = String((args && args.description) || '').replace(/\r?\n/g, ' ').trim();
25
+ const provider = String((args && args.provider) || '').trim();
26
+ const model = String((args && args.model) || '').trim();
27
+ // 思考强度:空值**不落盘**(与 `catalogDepth` 默认值同规矩)—— 免得每个新建的人设都多一行
28
+ // 说明"它和默认一样",也保证老的人设文件回写后逐字节不变。
29
+ const reasoningEffort = normalizeReasoningEffort(args?.reasoningEffort);
30
+ const tools = toStringList(args?.tools);
31
+ // 黑名单字段兼容两种入参名:toolsDeny(UI/camel)与 tools_deny(snake)。
32
+ const toolsDeny = toStringList(args?.toolsDeny ?? args?.tools_deny);
33
+ const byPreset = presetRulesOf(args?.toolsByPreset);
34
+ // 目录注入深度:只在显式给了合法值时写入。默认(1)**不落盘** —— 免得每个新建的人设都
35
+ // 多一行说明"它和默认一样",也保证老的人设文件回写后逐字节不变。
36
+ // 入参名兼容三种:catalogDepth(新)、maxDepth / max_depth(0.9.5 定稿前的旧名)。
37
+ const catalogDepthRaw = args?.catalogDepth ?? args?.catalog_depth ?? args?.maxDepth ?? args?.max_depth;
38
+ const catalogDepth = catalogDepthRaw === undefined || catalogDepthRaw === null || String(catalogDepthRaw).trim() === ''
39
+ ? undefined
40
+ : Number(String(catalogDepthRaw).trim());
41
+ const hasCatalogDepth = catalogDepth !== undefined && Number.isSafeInteger(catalogDepth) && catalogDepth >= 0 && catalogDepth !== DEFAULT_PERSONA_CATALOG_DEPTH;
42
+ const body = String((args && args.body) ?? '').trim();
43
+ // `output` 可传字符串(按行拆)或数组(界面直接给数组):一条要求写成一行重复键。
44
+ const outputLines = (Array.isArray(args?.output) ? args.output.map((v) => String(v)) : String(args?.output ?? '').split(/\r?\n/))
45
+ .map((line) => line.trim())
46
+ .filter((line) => line !== '');
47
+ const lines = ['---'];
48
+ if (description)
49
+ lines.push('description: ' + description);
50
+ if (provider)
51
+ lines.push('provider: ' + provider);
52
+ if (model)
53
+ lines.push('model: ' + model);
54
+ if (reasoningEffort)
55
+ lines.push('reasoningEffort: ' + reasoningEffort);
56
+ if (hasCatalogDepth)
57
+ lines.push('catalogDepth: ' + String(catalogDepth));
58
+ for (const line of outputLines)
59
+ lines.push('output: ' + line);
60
+ if (tools.length)
61
+ lines.push('tools: ' + tools.join(', '));
62
+ if (toolsDeny.length)
63
+ lines.push('toolsDeny: ' + toolsDeny.join(', '));
64
+ if (byPreset.length) {
65
+ lines.push('toolsByPreset:');
66
+ for (const [id, rule] of byPreset)
67
+ lines.push(' ' + id + ': ' + rule.mode + ': ' + rule.names.join(', '));
68
+ }
69
+ lines.push('---', '', body, '');
70
+ return lines.join('\n');
71
+ }
72
+ /**
73
+ * 规范化前端传来的 `toolsByPreset`:丢掉空名单(空 = 不限制 = 不落盘),
74
+ * 模式 id 只收形状合法的(预设 id 允许字母数字点横线)。
75
+ */
76
+ export function presetRulesOf(value) {
77
+ if (value === null || typeof value !== 'object')
78
+ return [];
79
+ const out = [];
80
+ for (const [rawId, rawRule] of Object.entries(value)) {
81
+ const id = String(rawId).trim();
82
+ if (!id || !/^[A-Za-z0-9._-]+$/.test(id))
83
+ continue;
84
+ if (rawRule === null || typeof rawRule !== 'object')
85
+ continue;
86
+ const rec = rawRule;
87
+ const names = toStringList(rec.names);
88
+ if (!names.length)
89
+ continue;
90
+ out.push([id, { mode: String(rec.mode) === 'deny' ? 'deny' : 'allow', names }]);
91
+ }
92
+ return out;
93
+ }
@@ -0,0 +1,159 @@
1
+ // 人设的**运行侧**:官方 provider 包是否挂载、按需取用,以及在父 agent 上跑一次委派。
2
+ //
3
+ // 正文从 subagents/service.ts 的 createSubagentService 闭包里整段搬来,一字未改 ——
4
+ // 只把三个闭包作用域的名字改走 deps(moved-verify 按这一类容差机检,容差之外一字不同就报错)。
5
+ //
6
+ // 为什么是这一刀:`createRequire(import.meta.url)` 造出来的那个 `req` **留在 service.ts**
7
+ // 并由 deps 传进来 —— provider 包是按 service.js 自己的位置解析的,把造 req 的那行搬走
8
+ // 就等于换了解析基准。传引用才谈得上逐字不变。
9
+ // 运行侧不碰人设清单的那份缓存(`cache` / `enabledCache` 都在 service.ts 那一层),
10
+ // 所以它只需要 ctx / req / message 三件。
11
+ import { emptyResultNote, renderPersonaPrompt, RESULT_MAX, textOfBlocks, } from './persona-parse.js';
12
+ // 拆出四层后 subagents/service.js 的公开面照旧(index.ts、ops/sessions.ts、scenes/candidates.ts、
13
+ // subagents/tools.ts 与契约测试都按这个路径引)。
14
+ export { catalogInjectedAt, catalogDepthOf, DEFAULT_PERSONA_CATALOG_DEPTH, emptyResultNote, neutralizePromptVariables, normalizeReasoningEffort, parsePersona, parsePresetToolRules, renderPersonaPrompt, REASONING_EFFORT_MAX_LENGTH, textOfBlocks, UNLIMITED_PERSONA_CATALOG_DEPTH, } from './persona-parse.js';
15
+ export { presetRulesOf, serializePersona, validPersonaName } from './persona-write.js';
16
+ export { decideToolFilter } from './tool-filter.js';
17
+ export function createRunProvider(deps) {
18
+ const PROVIDER_PACKAGES = {
19
+ spawn: { pkg: '@deepseek-ai/dsh-subagent-spawn-in-process', label: 'spawn(新会话)' },
20
+ fork: { pkg: '@deepseek-ai/dsh-subagent-fork-in-process', label: 'fork(继承本次会话)' },
21
+ };
22
+ const providerMounts = {};
23
+ function hasProvider(runtime, kind) {
24
+ if (typeof runtime.list !== 'function')
25
+ return false;
26
+ try {
27
+ const names = runtime.list();
28
+ return Array.isArray(names) && names.indexOf(kind) >= 0;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ async function ensureProvider(kind) {
35
+ const runtime = deps.ctx.get?.('subagents');
36
+ if (!runtime || typeof runtime.start !== 'function') {
37
+ throw new Error('子代理服务未挂载(ctx.subagents 缺失;请确认 DSH 版本 ≥0.1.5-rc.2)');
38
+ }
39
+ if (hasProvider(runtime, kind))
40
+ return runtime;
41
+ // 宿主没有 list() 就探测不了:不重复挂载(避免同名 provider 二次注册),交给 start 报官方错误兜底。
42
+ if (typeof runtime.list !== 'function')
43
+ return runtime;
44
+ if (!providerMounts[kind]) {
45
+ const { pkg, label } = PROVIDER_PACKAGES[kind];
46
+ try {
47
+ const mod = deps.req(pkg);
48
+ if (!mod || typeof mod.apply !== 'function')
49
+ throw new Error('模块未导出 apply(ctx, config)(版本不匹配?)');
50
+ mod.apply(deps.ctx, { providerName: kind });
51
+ providerMounts[kind] = { ok: true };
52
+ }
53
+ catch (e) {
54
+ providerMounts[kind] = {
55
+ ok: false,
56
+ error: `${label} provider 不可用:宿主没有注册它,本插件挂载 ${pkg} 也失败(`
57
+ + deps.message(e) + ')。请在宿主 profile 挂载该包(本插件已声明为可选 peerDependency),或安装后重启 DSH。',
58
+ };
59
+ }
60
+ }
61
+ const mount = providerMounts[kind];
62
+ if (!mount || !mount.ok)
63
+ throw new Error(mount ? mount.error : 'provider 挂载失败(未知原因)');
64
+ if (!hasProvider(runtime, kind))
65
+ throw new Error(`${kind} provider 挂载后仍未出现在 ctx.subagents.list()(宿主版本不匹配?)`);
66
+ return runtime;
67
+ }
68
+ async function runOnce(parentAgent, p, task, signal, toolFilter, inherit) {
69
+ // 这里**刻意没有**"预算用尽就拒绝"的前置检查(2026-09-17 用户实测后拆掉)。
70
+ // 它此前基于一个错误假设:以为传 `maxDepth: 1` 会让子会话再委派必然失败。实际上官方
71
+ // `dsh-tool-subagent` 的默认是 **3**、provider 只在**传了值**时才校验(`resolveChildDepth`),
72
+ // 所以子代理本来就能继续嵌套。那个检查唯一的净效果是让**我们的**工具比官方严 ——
73
+ // 同一个子会话里官方工具能委派、我们的被自己拦下 —— 而用户真想禁止嵌套也禁止不了。
74
+ //
75
+ // 通道选择(方案 C,2026-09-17):`inherit` → 官方 fork provider(子会话被**本次会话已完成的
76
+ // 对话**播种,`task` 只需写新增部分);默认 → spawn(全新会话,任务必须自包含)。
77
+ // 为什么必须有这一档:官方的 `subagent_fork` 靠"继承上下文"赢走过模型的选择 —— 本机实测
78
+ // (session-ee722e23)上下文里明明有「先在这里选人设…用 subagent_manager_run 执行」与
79
+ // `code-review` 人设,模型在**读进 README 之后**仍选了 `subagent_fork`:那能把已读内容带过去、
80
+ // 不必复述。人设通道缺这一档时,"贴合人设的任务"会持续被官方工具抢走 —— 补齐能力比改文案
81
+ // 更根本(模型选的是省力,不是没看见)。fork provider 的能力位(persona / toolFilter /
82
+ // agentOptions)与 spawn 相同,所以请求体不用分叉。
83
+ const kind = inherit === true ? 'fork' : 'spawn';
84
+ const runtime = await ensureProvider(kind);
85
+ // 工具限制三态:对象 = 调用方已按当前预设决定;null = 调用方明确决定不加限制;
86
+ // undefined = 调用方没决定(如别的入口直接调 runSerial)→ 回落人设里的旧格式全局名单。
87
+ const legacyFilter = (p.tools?.length || p.toolsDeny?.length)
88
+ ? { ...(p.tools?.length ? { allow: p.tools } : {}), ...(p.toolsDeny?.length ? { deny: p.toolsDeny } : {}) }
89
+ : null;
90
+ const resolved = toolFilter === undefined ? legacyFilter : toolFilter;
91
+ // 官方签名:start(name, request) —— name = ctx.subagents 上的 provider 注册名。
92
+ const run = await runtime.start(kind, {
93
+ label: p.name,
94
+ parent: parentAgent,
95
+ // 官方 SubagentStartRequest.signal 为必填:调用方缺省时给一个永不中止的信号,不传 undefined。
96
+ signal: signal ?? new AbortController().signal,
97
+ prompt: [{ type: 'text', text: task }],
98
+ // 人设不是裸正文:宿主的 persona 槽位(section order 0)原本是部署级短前缀,
99
+ // 框架该由填槽方承担。见 renderPersonaPrompt 的说明 —— 裸拼的后果是模型把它
100
+ // 读成身份句的续写,而不是一个被指定的角色。
101
+ persona: renderPersonaPrompt(p),
102
+ // 工具白/黑名单 → 官方 ToolRestriction(`deny` 优先级高于 `allow`;未知名官方会直接拒绝启动,
103
+ // 所以名单由 index.ts 按当前预设 + 当前真实存在的工具名算好再传进来,见 decideToolFilter)。
104
+ ...(resolved === null ? {} : { toolFilter: resolved }),
105
+ // **刻意不传 `maxDepth`**(2026-09-17 用户裁定,方案 A):它是官方的**真·递归上限**
106
+ // (`resolveChildDepth` 会抛 `SubagentDepthError`),而我们的 `catalogDepth` 只想决定
107
+ // "目录出现在哪"。传了它会让我们的工具比官方严(1 vs 3),且会要求 provider 具备
108
+ // `depthLimit` capability(不传就没这个依赖)。让 provider 用它自己的默认,与官方
109
+ // 工具的能力保持一致。
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
+ : {}),
130
+ });
131
+ try {
132
+ const result = await run.result; // 官方契约:child 级失败不 reject(stopReason 体现)
133
+ // 只取 `text` 块(见 textOfBlocks)。此前这里只判了 `typeof b.text === 'string'`,
134
+ // 于是 `reasoning` 块被当成正文 —— 子代理返回的正文里混着它的思考(计数、自我校验),
135
+ // 只有思考时整段返回思考。本机 298 条终局消息里 98.7% 命中。
136
+ const text = textOfBlocks(result.output, 'text');
137
+ // 官方 SubagentResult 的诊断字段是 `diagnostic`(旧代码读 .detail 恒空 → 失败时模型只见空输出)。
138
+ const diagnostic = result.diagnostic ? `\n\n[provider] ${String(result.diagnostic)}` : '';
139
+ const stopReason = String(result.stopReason ?? 'completed');
140
+ return {
141
+ text: ((text || emptyResultNote(result.output, stopReason)) + diagnostic).slice(0, RESULT_MAX),
142
+ runId: String(run.id ?? ''),
143
+ stopReason,
144
+ };
145
+ }
146
+ finally {
147
+ await run.dispose().catch(() => { }); // 官方前台语义:collection 后即弃
148
+ }
149
+ }
150
+ // v1 串行:同一时刻至多一个子代理运行(设计 §3.2)。
151
+ let chain = Promise.resolve();
152
+ const runSerial = (parentAgent, p, task, signal, toolFilter, inherit) => {
153
+ const start = () => runOnce(parentAgent, p, task, signal, toolFilter, inherit);
154
+ const queued = chain.then(start, start);
155
+ chain = queued.catch(() => undefined);
156
+ return queued;
157
+ };
158
+ return { PROVIDER_PACKAGES, providerMounts, hasProvider, ensureProvider, runOnce, runSerial };
159
+ }