dsh-plugin-tool-management 0.9.1 → 0.10.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.
@@ -1,6 +1,6 @@
1
1
  // src/subagents/service.ts —— 轻量子智能体:人设发现(TTL 扫描)+ 官方 ctx.subagents.start 薄封装。
2
2
  // 设计 §3:人设 = $DSH_HOME/tool-management/agents/<name>.md(frontmatter 可选,缺省派生);v1 串行运行;
3
- // spawn provider 缺失时经 createRequire 挂载官方 dsh-subagent-spawn-in-process(宿主侧包)。
3
+ // provider 缺失时经 createRequire 挂载宿主侧包:`spawn`(新会话,默认)与 `fork`(继承本次会话)。
4
4
  //
5
5
  // v0.4 目录变更:人设由 `$DSH_HOME/subagents/` 搬到 `$DSH_HOME/tool-management/agents/`
6
6
  // (插件产生的文件统一收在 tool-management/ 下)。旧目录在首次扫描时搬入,见 relocateLegacyPersonas。
@@ -97,17 +97,27 @@ export function parsePresetToolRules(frontmatter) {
97
97
  }
98
98
  return Object.keys(rules).length ? rules : undefined;
99
99
  }
100
- /** 行式 frontmatter 解析:只认 description / provider / model / tools / toolsDeny / toolsByPreset。 */
100
+ /** 行式 frontmatter 解析:只认 description / provider / model / tools / toolsDeny / toolsByPreset / catalogDepth / output。 */
101
101
  export function parsePersona(raw, fallbackName) {
102
102
  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
103
103
  const data = {};
104
+ const outputLines = [];
104
105
  let body = raw;
105
106
  let toolsByPreset;
106
107
  if (m) {
107
108
  for (const line of m[1].split(/\r?\n/)) {
108
109
  const kv = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(line.trim());
109
- if (kv)
110
- data[kv[1].toLowerCase()] = kv[2].trim();
110
+ if (!kv)
111
+ continue;
112
+ const key = kv[1].toLowerCase();
113
+ // `output:` 是**可重复**的键(一条要求一行,按顺序收集)。frontmatter 是逐行解析的,
114
+ // 多行契约塞不进一个值里;写成重复键比引入块标量语法简单,也一眼看得懂。
115
+ if (key === 'output') {
116
+ if (kv[2].trim() !== '')
117
+ outputLines.push(kv[2].trim());
118
+ continue;
119
+ }
120
+ data[key] = kv[2].trim();
111
121
  }
112
122
  toolsByPreset = parsePresetToolRules(m[1]);
113
123
  body = raw.slice(m[0].length);
@@ -116,6 +126,7 @@ export function parsePersona(raw, fallbackName) {
116
126
  // 兼容两种写法:`toolsdeny: a, b`(线上格式)与 `toolsDeny: a, b`(键名统一小写后同形)。
117
127
  const toolsDeny = listOf(data.toolsdeny);
118
128
  const firstLine = body.split(/\r?\n/).find((l) => l.trim())?.trim() ?? '';
129
+ const catalogDepth = parseCatalogDepth(data);
119
130
  return {
120
131
  name: fallbackName,
121
132
  description: data.description || firstLine,
@@ -124,11 +135,222 @@ export function parsePersona(raw, fallbackName) {
124
135
  tools,
125
136
  toolsDeny,
126
137
  ...(toolsByPreset === undefined ? {} : { toolsByPreset }),
138
+ ...(catalogDepth === undefined ? {} : { catalogDepth }),
139
+ ...(outputLines.length ? { output: outputLines.join('\n') } : {}),
127
140
  body: body.trim(),
128
141
  path: '',
129
142
  };
130
143
  }
144
+ /** 零宽空格:用来拆开 `{{`,视觉上不留痕。 */
145
+ const ZERO_WIDTH = '\u200b';
146
+ /**
147
+ * 拆开正文里的 `{{`,让它不再被宿主当作提示词变量引用。
148
+ *
149
+ * 宿主对 section 文本做**严格**变量插值(dsh-system-prompt 的 `interpolate`):
150
+ * 命中 `{{name}}` 而该变量没注册就抛错,整个子代理启动失败。人设是用户自由文本,
151
+ * 作者写 `{{foo}}` 几乎一定是字面量,却会让委派直接崩掉。这里在两个花括号之间插一个
152
+ * 零宽空格:模型看到的仍是 `{{foo}}`,宿主再也找不到 `{{`。**只拆开,不删除**——
153
+ * 用户写下的每一个可见字符都保留。
154
+ */
155
+ export function neutralizePromptVariables(text) {
156
+ // 在"后面还是左花括号"的 `{` 之后插入零宽空格,把 `{{` 拆成 `{<ZWSP>{`。
157
+ // 用前瞻断言而不是 `replace(/\{\{/g, …)`:后者的替换串**尾字符也是 `{`**,
158
+ // 遇到 `{{{` 会与被替换掉的首括号后面的原字符重新拼出 `{{`(实测踩到)。
159
+ // 前瞻写法一次扫描就够,连续多少个左括号都处理干净。
160
+ return text.replace(/\{(?=\{)/g, `{${ZERO_WIDTH}`);
161
+ }
162
+ /**
163
+ * 这个人设主要用中文写的?启发式,判错的代价只是框的语言不搭。
164
+ *
165
+ * 判定范围是**人设整体**(名字 + 描述 + 正文),不只是正文:正文可能是占位符、
166
+ * 代码片段或纯英文标识(实测有人的正文是 `123`),只看正文会把一个中文人设
167
+ * 判成英文、配上英文框。名字与描述是作者写的,同样能说明语言。
168
+ */
169
+ function isChinesePersona(name, description, body) {
170
+ const sample = `${name}\n${description}\n${body}`;
171
+ const cjk = (sample.match(/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g) || []).length;
172
+ if (cjk === 0)
173
+ return false;
174
+ const latin = (sample.match(/[A-Za-z]/g) || []).length;
175
+ // 汉字信息密度高于字母,1 个汉字按 2 个字母折算,避免"中文正文夹少量英文术语"被判成英文。
176
+ return cjk * 2 >= latin;
177
+ }
178
+ /**
179
+ * 人设 → 子代理系统提示词里那一段的**成品文本**(2026-09-17)。
180
+ *
181
+ * 为什么需要这层框:宿主给子代理的 persona 只占一个槽位 —— `deployment:persona-prefix`,
182
+ * section order 0,位置紧贴 `harness:identity`(order -1000,那句 "You are an AI agent
183
+ * powered by DeepSeek Harness.")。而宿主自己的 `personaPrefix` 配置是**部署级短前缀**,
184
+ * 它默认由部署方承担框架。我们把整篇人设正文直接填进这个槽,等于交出一段无标题、无归属、
185
+ * 无授权的裸文本:模型读到的是"身份句后面又跟了两句话",而不是"我被指定为一个角色"。
186
+ * 用户写的 `必须…` 因此只是背景信息,没有上位效力。
187
+ *
188
+ * 两份参考实现都靠**框**解决同一件事:Claude Code 把代理提示词放在系统提示词**首位**,
189
+ * 追加内容一律带 `#`/`##` 标题与指令段(`AgentTool/agentMemory.ts`、`memdir/memdir.ts`);
190
+ * Codex 在指令文件交界处插入来源标记 `--- project-doc ---`,让模型知道权威边界从哪开始
191
+ * (`core/src/agents_md.rs`)。两者都没有"把用户正文裸拼进系统提示词"这种做法。
192
+ *
193
+ * 框里四样东西各有分工,缺一样就退化成原来的裸文本:
194
+ * - `# 角色:名` —— 结构信号(markdown 标题是模型最强的分段线索)+ 可引用的名字;
195
+ * - 归属 + 授权一行 —— "由调用方指定、与默认倾向冲突时以它为准",给正文上位效力;
196
+ * 2026-09-17 补了半句"任务说要什么,怎么做以它为准":不写这句,模型会把 `task`
197
+ * (父代理按自己的框架写的具体指令)读成人设的上级,人设只剩风格提示的分量;
198
+ * - 边界一句 —— 角色决定**怎么做**,不改变**能做什么**。这不是装饰:官方
199
+ * `subagent:delegation` 运行时上下文已经声明"权限在启动时固定",两处不呼应的话,
200
+ * 写着"你可以随意写文件"的人设会读起来像与工具限制矛盾;
201
+ * - `## 角色定义` 标题 + 正文 —— 明确起止,把用户文本围起来。
202
+ *
203
+ * 语言跟随人设整体(名字 + 描述 + 正文):中文人设配中文框,英文配英文。
204
+ *
205
+ * 框里**不列 `description`**(2026-09-17 用户裁定,此前列):那个字段的用途是**给调用方
206
+ * 选用**(本机那份写的是"当用户提到审查或使用审查子智能体时使用"),对已经在执行的人设
207
+ * 子代理是错位信息 —— 可能被读成"满足某个条件才按这个角色"的前置条件。要给人设子代理
208
+ * 看的简介属于正文(`description` 仍照常参与语言判定与目录展示)。
209
+ *
210
+ * 正文为空时退化成只给一个名字 —— 空框比没有框更糟。
211
+ */
212
+ export function renderPersonaPrompt(persona) {
213
+ // 名字与正文/输出过同一道中和:名字原样进框时,宿主对 section 文本的严格插值会把
214
+ // `{{…}}` 当未注册变量抛错,委派直接硬失败 —— 0.9.5 的中和只盖了正文与输出,漏了名字
215
+ // (validPersonaName 不挡花括号,手写 frontmatter 造得出这种名字)。
216
+ const name = neutralizePromptVariables(String(persona.name || '').trim()) || '(unnamed)';
217
+ const raw = String(persona.body ?? '');
218
+ const body = neutralizePromptVariables(raw.trim());
219
+ if (body === '')
220
+ return name;
221
+ const rawDescription = String(persona.description ?? '').trim();
222
+ const output = neutralizePromptVariables(String(persona.output ?? '').trim());
223
+ const lines = isChinesePersona(name, rawDescription, raw)
224
+ ? [
225
+ `# 角色:${name}`,
226
+ '',
227
+ `你正在以「${name}」的身份执行本次委派任务。以下角色定义由调用方指定,是你本次运行的固定行为准则:与你的默认倾向冲突时,以它为准;任务说要什么,怎么做以它为准。它决定你如何工作,不改变你的权限范围。`,
228
+ '',
229
+ '## 角色定义',
230
+ '',
231
+ body,
232
+ ...(output === '' ? [] : ['', '## 输出要求(硬性)', '', output]),
233
+ ]
234
+ : [
235
+ `# Persona: ${name}`,
236
+ '',
237
+ `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.`,
238
+ '',
239
+ '## Persona',
240
+ '',
241
+ body,
242
+ ...(output === '' ? [] : ['', '## Output requirements (hard)', '', output]),
243
+ ];
244
+ return lines.join('\n');
245
+ }
246
+ /** 一个人设没写 `catalogDepth` 时的目录注入深度:1 —— 只在顶层注入目录。 */
247
+ export const DEFAULT_PERSONA_CATALOG_DEPTH = 1;
248
+ /**
249
+ * 「不限制嵌套」的目录注入深度(界面下拉的第四档,前端同值见 client.js 的
250
+ * `CATALOG_DEPTH_UNLIMITED`)。
251
+ *
252
+ * 取值远大于宿主能嵌套到的深度(官方 `dsh-tool-subagent` 默认 `maxDepth: 3`),所以判据
253
+ * `深度 < catalogDepth` 在任何可达的会话里都成立 —— 判据本身不必为它加特例分支。
254
+ */
255
+ export const UNLIMITED_PERSONA_CATALOG_DEPTH = 99;
256
+ /**
257
+ * 这个人设的目录注入深度(非法值一律退回默认,**默认从不放宽**)。
258
+ *
259
+ * 为什么非法值退回默认而不是报错:`catalogDepth` 是 frontmatter 里手写的字段,写错一个
260
+ * 数字不该让整个人设不可用;而"退回默认"是安全方向 —— 默认只注入到顶层,噪声最小。
261
+ */
262
+ export function catalogDepthOf(persona) {
263
+ const value = persona?.catalogDepth;
264
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
265
+ return DEFAULT_PERSONA_CATALOG_DEPTH;
266
+ return value;
267
+ }
268
+ /**
269
+ * 深度为 `depth` 的会话里,该不该注入这个人设所在的目录?
270
+ *
271
+ * 判据是 `depth < 目录注入深度`。**这个函数是三处口径的唯一来源** —— 目录要不要列出、
272
+ * 域该不该注入、实况报什么状态,全都问它。各写一份必然分叉。
273
+ *
274
+ * ⚠️ 它回答的**不是**"能不能委派"(那由官方决定,且默认能嵌套到 3 层)。这个区分是
275
+ * 2026-09-17 用户实测后纠正的:此前函数名是 `canDelegateTo`,把"目录可见性"当成了
276
+ * "委派可行性",于是同一个子会话里官方工具能委派、我们的工具被自己的守卫拦下。
277
+ */
278
+ export function catalogInjectedAt(persona, depth) {
279
+ const from = Number.isSafeInteger(depth) && depth >= 0 ? depth : 0;
280
+ return from < catalogDepthOf(persona);
281
+ }
282
+ /** frontmatter 里目录注入深度的几种写法都认(含旧的 `maxDepth`,见下)。 */
283
+ function parseCatalogDepth(data) {
284
+ // 旧键 `maxDepth` 也读:0.9.5 定稿前这个字段叫过那个名字,且当时语义是"递归上限"。
285
+ // 读它只是为了不让手写过旧键的文件静默失效;**写回时一律写新键**(见 serializePersona)。
286
+ const raw = data.catalogdepth ?? data['catalog-depth'] ?? data.catalog_depth ?? data.maxdepth ?? data['max-depth'] ?? data.max_depth;
287
+ if (raw === undefined || raw.trim() === '')
288
+ return undefined;
289
+ const value = Number(raw.trim());
290
+ if (!Number.isSafeInteger(value) || value < 0)
291
+ return undefined;
292
+ return value;
293
+ }
131
294
  const RESULT_MAX = 16 * 1024;
295
+ /**
296
+ * 从内容块数组里按 `type` 取文本并拼接 —— 官方 `finalText` 的口径
297
+ * (`dsh-subagent/lib/index.js:2658`:`blocks.filter(block => block.type === "text")`;
298
+ * `dsh-tool-subagent` 的 `outputValueText`、`withDiagnosticAndPartialText` 同样如此)。
299
+ *
300
+ * **必须按 `type` 过滤,不能只判有没有 `text` 字段**:`ContentBlock` 里 `TextBlock` 与
301
+ * `ReasoningBlock` 的结构完全相同(都是 `{ type, text: string }`,见 dsh-llm 的
302
+ * `types.d.ts:39/44`),只判字段会把**思考**当成正文拼进去。本机 298 条子代理终局消息里
303
+ * 294 条受影响:211 条思考与正文并存(返回结果的开头变成「我做了 1、2、3」这类自我校验,
304
+ * 正文被顶到后面),83 条压根没有正文。
305
+ *
306
+ * `typeof b.text === 'string'` 这层是防 provider 给出畸形块(官方工具也这么防)。
307
+ */
308
+ export function textOfBlocks(blocks, type) {
309
+ if (!Array.isArray(blocks))
310
+ return '';
311
+ return blocks
312
+ .filter((b) => b !== null && typeof b === 'object' && b.type === type && typeof b.text === 'string')
313
+ .map((b) => b.text)
314
+ .join('')
315
+ .trim();
316
+ }
317
+ /**
318
+ * 没有正文时给调用方的一句话。
319
+ *
320
+ * 要解决的是**误读**:原来一律回 `(子代理无输出)`,而「子代理没干活」和「干了活但没写出
321
+ * 正文」是两件事,报成同一句会让调用方把它当成一次空跑。所以这一句里必须同时说清两件事:
322
+ * ① 它**干了活**(产出了思考)—— 否则会被读成空跑;
323
+ * ② 这次委派**没有拿到可用结果** —— 否则会被当成一份内容读下去。
324
+ *
325
+ * 按收尾原因分档,而不是给一句通用话:实测数据推翻了「它忘了写结论」这个想当然的解释 ——
326
+ * 本机 83 条「没有正文」的终局里**没有一条是 `completed`**(max-tokens 36、error 20、
327
+ * 无收尾记录 22、aborted 5),而有正文那批 215 条里 195 条是 `completed`。官方自己也是
328
+ * 这么分的:`dsh-tool-subagent/lib/index.js:316` 对任何非 `completed` 的收尾**直接抛错**
329
+ * (`stopReasonError` → `withDiagnosticAndPartialText`),只有 `completed` 才当结果返回。
330
+ * 所以这个兜底出现的场合,对策基本都是「拆小任务 / 查诊断 / 换人设」,不是原样再发一次。
331
+ *
332
+ * 分档用的是官方 `stopReasonError` 认的那几个值,出现新值时落回最后那句通用说明。
333
+ *
334
+ * 刻意**不把思考正文塞回来**:那正是这次要修的泄漏,换个标签塞回去等于没修。
335
+ */
336
+ export function emptyResultNote(output, stopReason) {
337
+ // 连思考都没有 → 子代理确实什么都没产出,这时说「没干活」是对的。
338
+ if (textOfBlocks(output, 'reasoning') === '')
339
+ return '(子代理无输出)';
340
+ const head = '子代理没有返回正文 —— 它产出了思考';
341
+ switch (stopReason) {
342
+ case 'max-tokens':
343
+ return `(${head},但写出结论前就用完了 token 上限;需要结果请把任务拆小些再委派。)`;
344
+ case 'error':
345
+ return `(${head},但中途出错了;诊断信息见后。)`;
346
+ case 'aborted':
347
+ return `(${head},但写出结论前被中止了。)`;
348
+ case 'refusal':
349
+ return `(${head},但它拒绝了这项任务。)`;
350
+ default:
351
+ return `(${head},但没有写出结论;可以拆小任务重新委派,或你自己接着做。)`;
352
+ }
353
+ }
132
354
  export function createSubagentService(ctx, opts) {
133
355
  const dir = opts?.subagentsDir || defaultPersonasDir();
134
356
  const stateDir = opts?.stateDir && opts.stateDir.trim() !== '' ? opts.stateDir : join(resolveDshHome(), 'tool-management');
@@ -222,55 +444,79 @@ export function createSubagentService(ctx, opts) {
222
444
  }
223
445
  return withEnabled(docs, await readEnabled());
224
446
  }
225
- // spawn provider 在场性(设计 §3.2,落地方式见 cordis.patch.yml 的取舍注释):
447
+ // provider 在场性(设计 §3.2,落地方式见 cordis.patch.yml 的取舍注释):
226
448
  // 官方通道探测(ctx.subagents.list())→ 缺失才挂载 → 失败把原因原样带出(不吞、不谎报)。
227
449
  // 不是一次性静默标记:每次调用都先探测,宿主后来注册了 provider 也能立刻跟上。
228
- let spawnMount = null;
229
- function hasSpawnProvider(runtime) {
450
+ //
451
+ // 两个 provider(2026-09-17 加 fork,理由见 runOnce 的通道说明):
452
+ // spawn = 新起的独立会话(默认);fork = **继承本次会话已完成的对话**(官方 subagent_fork 用的是它)。
453
+ const PROVIDER_PACKAGES = {
454
+ spawn: { pkg: '@deepseek-ai/dsh-subagent-spawn-in-process', label: 'spawn(新会话)' },
455
+ fork: { pkg: '@deepseek-ai/dsh-subagent-fork-in-process', label: 'fork(继承本次会话)' },
456
+ };
457
+ const providerMounts = {};
458
+ function hasProvider(runtime, kind) {
230
459
  if (typeof runtime.list !== 'function')
231
460
  return false;
232
461
  try {
233
462
  const names = runtime.list();
234
- return Array.isArray(names) && names.indexOf('spawn') >= 0;
463
+ return Array.isArray(names) && names.indexOf(kind) >= 0;
235
464
  }
236
465
  catch {
237
466
  return false;
238
467
  }
239
468
  }
240
- async function ensureSpawnProvider() {
469
+ async function ensureProvider(kind) {
241
470
  const runtime = ctx.get?.('subagents');
242
471
  if (!runtime || typeof runtime.start !== 'function') {
243
472
  throw new Error('子代理服务未挂载(ctx.subagents 缺失;请确认 DSH 版本 ≥0.1.5-rc.2)');
244
473
  }
245
- if (hasSpawnProvider(runtime))
474
+ if (hasProvider(runtime, kind))
246
475
  return runtime;
247
476
  // 宿主没有 list() 就探测不了:不重复挂载(避免同名 provider 二次注册),交给 start 报官方错误兜底。
248
477
  if (typeof runtime.list !== 'function')
249
478
  return runtime;
250
- if (!spawnMount) {
479
+ if (!providerMounts[kind]) {
480
+ const { pkg, label } = PROVIDER_PACKAGES[kind];
251
481
  try {
252
- const mod = req('@deepseek-ai/dsh-subagent-spawn-in-process');
482
+ const mod = req(pkg);
253
483
  if (!mod || typeof mod.apply !== 'function')
254
484
  throw new Error('模块未导出 apply(ctx, config)(版本不匹配?)');
255
- mod.apply(ctx, { providerName: 'spawn' });
256
- spawnMount = { ok: true };
485
+ mod.apply(ctx, { providerName: kind });
486
+ providerMounts[kind] = { ok: true };
257
487
  }
258
488
  catch (e) {
259
- spawnMount = {
489
+ providerMounts[kind] = {
260
490
  ok: false,
261
- error: 'spawn provider 不可用:宿主没有注册它,本插件挂载 @deepseek-ai/dsh-subagent-spawn-in-process 也失败('
491
+ error: `${label} provider 不可用:宿主没有注册它,本插件挂载 ${pkg} 也失败(`
262
492
  + message(e) + ')。请在宿主 profile 挂载该包(本插件已声明为可选 peerDependency),或安装后重启 DSH。',
263
493
  };
264
494
  }
265
495
  }
266
- if (!spawnMount.ok)
267
- throw new Error(spawnMount.error);
268
- if (!hasSpawnProvider(runtime))
269
- throw new Error('spawn provider 挂载后仍未出现在 ctx.subagents.list()(宿主版本不匹配?)');
496
+ const mount = providerMounts[kind];
497
+ if (!mount || !mount.ok)
498
+ throw new Error(mount ? mount.error : 'provider 挂载失败(未知原因)');
499
+ if (!hasProvider(runtime, kind))
500
+ throw new Error(`${kind} provider 挂载后仍未出现在 ctx.subagents.list()(宿主版本不匹配?)`);
270
501
  return runtime;
271
502
  }
272
- async function runOnce(parentAgent, p, task, signal, toolFilter) {
273
- const runtime = await ensureSpawnProvider();
503
+ async function runOnce(parentAgent, p, task, signal, toolFilter, inherit) {
504
+ // 这里**刻意没有**"预算用尽就拒绝"的前置检查(2026-09-17 用户实测后拆掉)。
505
+ // 它此前基于一个错误假设:以为传 `maxDepth: 1` 会让子会话再委派必然失败。实际上官方
506
+ // `dsh-tool-subagent` 的默认是 **3**、provider 只在**传了值**时才校验(`resolveChildDepth`),
507
+ // 所以子代理本来就能继续嵌套。那个检查唯一的净效果是让**我们的**工具比官方严 ——
508
+ // 同一个子会话里官方工具能委派、我们的被自己拦下 —— 而用户真想禁止嵌套也禁止不了。
509
+ //
510
+ // 通道选择(方案 C,2026-09-17):`inherit` → 官方 fork provider(子会话被**本次会话已完成的
511
+ // 对话**播种,`task` 只需写新增部分);默认 → spawn(全新会话,任务必须自包含)。
512
+ // 为什么必须有这一档:官方的 `subagent_fork` 靠"继承上下文"赢走过模型的选择 —— 本机实测
513
+ // (session-ee722e23)上下文里明明有「先在这里选人设…用 subagent_manager_run 执行」与
514
+ // `code-review` 人设,模型在**读进 README 之后**仍选了 `subagent_fork`:那能把已读内容带过去、
515
+ // 不必复述。人设通道缺这一档时,"贴合人设的任务"会持续被官方工具抢走 —— 补齐能力比改文案
516
+ // 更根本(模型选的是省力,不是没看见)。fork provider 的能力位(persona / toolFilter /
517
+ // agentOptions)与 spawn 相同,所以请求体不用分叉。
518
+ const kind = inherit === true ? 'fork' : 'spawn';
519
+ const runtime = await ensureProvider(kind);
274
520
  // 工具限制三态:对象 = 调用方已按当前预设决定;null = 调用方明确决定不加限制;
275
521
  // undefined = 调用方没决定(如别的入口直接调 runSerial)→ 回落人设里的旧格式全局名单。
276
522
  const legacyFilter = (p.tools?.length || p.toolsDeny?.length)
@@ -278,17 +524,24 @@ export function createSubagentService(ctx, opts) {
278
524
  : null;
279
525
  const resolved = toolFilter === undefined ? legacyFilter : toolFilter;
280
526
  // 官方签名:start(name, request) —— name = ctx.subagents 上的 provider 注册名。
281
- const run = await runtime.start('spawn', {
527
+ const run = await runtime.start(kind, {
282
528
  label: p.name,
283
529
  parent: parentAgent,
284
530
  // 官方 SubagentStartRequest.signal 为必填:调用方缺省时给一个永不中止的信号,不传 undefined。
285
531
  signal: signal ?? new AbortController().signal,
286
532
  prompt: [{ type: 'text', text: task }],
287
- persona: p.body,
533
+ // 人设不是裸正文:宿主的 persona 槽位(section order 0)原本是部署级短前缀,
534
+ // 框架该由填槽方承担。见 renderPersonaPrompt 的说明 —— 裸拼的后果是模型把它
535
+ // 读成身份句的续写,而不是一个被指定的角色。
536
+ persona: renderPersonaPrompt(p),
288
537
  // 工具白/黑名单 → 官方 ToolRestriction(`deny` 优先级高于 `allow`;未知名官方会直接拒绝启动,
289
538
  // 所以名单由 index.ts 按当前预设 + 当前真实存在的工具名算好再传进来,见 decideToolFilter)。
290
539
  ...(resolved === null ? {} : { toolFilter: resolved }),
291
- maxDepth: 1,
540
+ // **刻意不传 `maxDepth`**(2026-09-17 用户裁定,方案 A):它是官方的**真·递归上限**
541
+ // (`resolveChildDepth` 会抛 `SubagentDepthError`),而我们的 `catalogDepth` 只想决定
542
+ // "目录出现在哪"。传了它会让我们的工具比官方严(1 vs 3),且会要求 provider 具备
543
+ // `depthLimit` capability(不传就没这个依赖)。让 provider 用它自己的默认,与官方
544
+ // 工具的能力保持一致。
292
545
  // provider 与 model 是模型路由的两半:DSH 的 resolveModel(provider, model) 不做
293
546
  // `provider/model` 字符串拆分,只改 model 会落在**主会话的 provider** 上——跨来源
294
547
  // 指定模型(如 sensenova 的 sensenova-6.8-flash-lite)必须两个键一起给。
@@ -298,15 +551,17 @@ export function createSubagentService(ctx, opts) {
298
551
  });
299
552
  try {
300
553
  const result = await run.result; // 官方契约:child 级失败不 reject(stopReason 体现)
301
- const text = (result.output ?? [])
302
- .map((b) => (b && typeof b === 'object' && typeof b.text === 'string' ? b.text : ''))
303
- .join('').trim();
554
+ // 只取 `text` 块(见 textOfBlocks)。此前这里只判了 `typeof b.text === 'string'`,
555
+ // 于是 `reasoning` 块被当成正文 —— 子代理返回的正文里混着它的思考(计数、自我校验),
556
+ // 只有思考时整段返回思考。本机 298 条终局消息里 98.7% 命中。
557
+ const text = textOfBlocks(result.output, 'text');
304
558
  // 官方 SubagentResult 的诊断字段是 `diagnostic`(旧代码读 .detail 恒空 → 失败时模型只见空输出)。
305
559
  const diagnostic = result.diagnostic ? `\n\n[provider] ${String(result.diagnostic)}` : '';
560
+ const stopReason = String(result.stopReason ?? 'completed');
306
561
  return {
307
- text: ((text || '(子代理无输出)') + diagnostic).slice(0, RESULT_MAX),
562
+ text: ((text || emptyResultNote(result.output, stopReason)) + diagnostic).slice(0, RESULT_MAX),
308
563
  runId: String(run.id ?? ''),
309
- stopReason: String(result.stopReason ?? 'completed'),
564
+ stopReason,
310
565
  };
311
566
  }
312
567
  finally {
@@ -315,8 +570,9 @@ export function createSubagentService(ctx, opts) {
315
570
  }
316
571
  // v1 串行:同一时刻至多一个子代理运行(设计 §3.2)。
317
572
  let chain = Promise.resolve();
318
- const runSerial = (parentAgent, p, task, signal, toolFilter) => {
319
- const queued = chain.then(() => runOnce(parentAgent, p, task, signal, toolFilter), () => runOnce(parentAgent, p, task, signal, toolFilter));
573
+ const runSerial = (parentAgent, p, task, signal, toolFilter, inherit) => {
574
+ const start = () => runOnce(parentAgent, p, task, signal, toolFilter, inherit);
575
+ const queued = chain.then(start, start);
320
576
  chain = queued.catch(() => undefined);
321
577
  return queued;
322
578
  };
@@ -325,7 +581,8 @@ export function createSubagentService(ctx, opts) {
325
581
  const docs = await list();
326
582
  return {
327
583
  ok: true,
328
- subagents: docs.map((p) => ({ name: p.name, enabled: p.enabled !== false, description: p.description, provider: p.provider ?? null, model: p.model ?? null, tools: p.tools ?? null, toolsDeny: p.toolsDeny ?? null, toolsByPreset: p.toolsByPreset ?? null })),
584
+ // catalogDepth 报**生效值**(没写就是默认 1),界面与模型都不必各自知道默认是多少。
585
+ subagents: docs.map((p) => ({ name: p.name, enabled: p.enabled !== false, description: p.description, provider: p.provider ?? null, model: p.model ?? null, tools: p.tools ?? null, toolsDeny: p.toolsDeny ?? null, toolsByPreset: p.toolsByPreset ?? null, catalogDepth: catalogDepthOf(p), output: p.output ?? null })),
329
586
  };
330
587
  },
331
588
  'subagent-get': async (args) => {
@@ -334,7 +591,7 @@ export function createSubagentService(ctx, opts) {
334
591
  const p = docs.find((d) => d.name === name);
335
592
  if (!p)
336
593
  return { ok: false, error: `人设不存在: ${name}` };
337
- return { ok: true, persona: { name: p.name, description: p.description, provider: p.provider ?? '', model: p.model ?? '', tools: p.tools ?? [], toolsDeny: p.toolsDeny ?? [], toolsByPreset: p.toolsByPreset ?? {}, body: p.body } };
594
+ return { ok: true, persona: { name: p.name, description: p.description, provider: p.provider ?? '', model: p.model ?? '', tools: p.tools ?? [], toolsDeny: p.toolsDeny ?? [], toolsByPreset: p.toolsByPreset ?? {}, catalogDepth: catalogDepthOf(p), output: p.output ?? '', body: p.body } };
338
595
  },
339
596
  'subagent-create': async (args) => {
340
597
  const name = String((args && args.name) || '').trim();
@@ -611,7 +868,19 @@ export function serializePersona(args) {
611
868
  // 黑名单字段兼容两种入参名:toolsDeny(UI/camel)与 tools_deny(snake)。
612
869
  const toolsDeny = toStringList(args?.toolsDeny ?? args?.tools_deny);
613
870
  const byPreset = presetRulesOf(args?.toolsByPreset);
871
+ // 目录注入深度:只在显式给了合法值时写入。默认(1)**不落盘** —— 免得每个新建的人设都
872
+ // 多一行说明"它和默认一样",也保证老的人设文件回写后逐字节不变。
873
+ // 入参名兼容三种:catalogDepth(新)、maxDepth / max_depth(0.9.5 定稿前的旧名)。
874
+ const catalogDepthRaw = args?.catalogDepth ?? args?.catalog_depth ?? args?.maxDepth ?? args?.max_depth;
875
+ const catalogDepth = catalogDepthRaw === undefined || catalogDepthRaw === null || String(catalogDepthRaw).trim() === ''
876
+ ? undefined
877
+ : Number(String(catalogDepthRaw).trim());
878
+ const hasCatalogDepth = catalogDepth !== undefined && Number.isSafeInteger(catalogDepth) && catalogDepth >= 0 && catalogDepth !== DEFAULT_PERSONA_CATALOG_DEPTH;
614
879
  const body = String((args && args.body) ?? '').trim();
880
+ // `output` 可传字符串(按行拆)或数组(界面直接给数组):一条要求写成一行重复键。
881
+ const outputLines = (Array.isArray(args?.output) ? args.output.map((v) => String(v)) : String(args?.output ?? '').split(/\r?\n/))
882
+ .map((line) => line.trim())
883
+ .filter((line) => line !== '');
615
884
  const lines = ['---'];
616
885
  if (description)
617
886
  lines.push('description: ' + description);
@@ -619,6 +888,10 @@ export function serializePersona(args) {
619
888
  lines.push('provider: ' + provider);
620
889
  if (model)
621
890
  lines.push('model: ' + model);
891
+ if (hasCatalogDepth)
892
+ lines.push('catalogDepth: ' + String(catalogDepth));
893
+ for (const line of outputLines)
894
+ lines.push('output: ' + line);
622
895
  if (tools.length)
623
896
  lines.push('tools: ' + tools.join(', '));
624
897
  if (toolsDeny.length)
@@ -1,3 +1,6 @@
1
+ // 深度探针(`subagentDepthOf`)与目录判据(`catalogInjectedAt`)都不再需要:
2
+ // 2026-09-17 方案 A 之后,这两个工具都不再看会话深度 —— 委派由官方决定(默认能嵌套到 3 层),
3
+ // `catalogDepth` 只管常驻目录注入到哪些会话。
1
4
  export function text(v) {
2
5
  return [{ type: 'text', text: v }];
3
6
  }
@@ -19,7 +22,7 @@ export async function filterBySceneBinding(docs, enabledSceneLists) {
19
22
  export function defineSubagentManagerListTool(subagents) {
20
23
  return {
21
24
  name: 'subagent_manager_list',
22
- description: 'List available personas (pre-configured subagent profiles) with their descriptions. Call before subagent_manager_run.',
25
+ description: 'List available personas (pre-configured subagent profiles) with their descriptions. The same catalog is injected into your context each turn (the「可委派的子智能体」system-reminder); call this tool before subagent_manager_run for the full, always-current list.',
23
26
  parameters: {},
24
27
  output: {
25
28
  schema: { type: 'string' },
@@ -27,6 +30,10 @@ export function defineSubagentManagerListTool(subagents) {
27
30
  },
28
31
  async execute(_args, exec) {
29
32
  const { allowed, reason } = await filterBySceneBinding(await subagents.list(), await subagents.sceneLists());
33
+ // 这里**不再按深度过滤**(2026-09-17 用户裁定,方案 A):`catalogDepth` 只决定常驻目录
34
+ // 注入到哪些会话,与"能不能委派"无关 —— 委派由官方决定(`dsh-tool-subagent` 默认能嵌套
35
+ // 到 3 层)。此前按深度过滤等于把可用人设藏起来:目录不注入时模型只能靠这个工具查,
36
+ // 而工具又不列全,结果是"明明能委派却查不到人设"。
30
37
  const lines = allowed.map((p) => '- ' + p.name + ' — ' + (p.description || '(无描述)'));
31
38
  let notice = '';
32
39
  try {
@@ -42,10 +49,21 @@ export function defineSubagentManagerListTool(subagents) {
42
49
  export function defineSubagentManagerRunTool(subagents) {
43
50
  return {
44
51
  name: 'subagent_manager_run',
45
- description: 'Run a named persona as a one-shot subagent: it gets the persona as its own system prompt, works on `task` in a fresh context, and returns only its final output. Stateless — it does not see this conversation. Expensive: starts a fresh model session, so use it only for self-contained work.',
52
+ // 描述按「是什么 → 两种模式 → 何时用(含与官方两个委派工具的分界)→ 何时改用别的」组织。
53
+ // 2026-09-17 补了**分界规则**与 `inherit`:此前上下文里虽然注入了人设目录与「用
54
+ // subagent_manager_run 执行」,但没有任何一句话说明它和官方 `subagent` / `subagent_fork`
55
+ // 何时该用谁 —— 本机实测(session-ee722e23)模型读完 README 后选了官方 `subagent_fork`
56
+ // (fork 能继承已读内容、不必复述)。现在人设通道也有 fork(`inherit`),分界只剩
57
+ // 「要不要后台跑」一件事,所以规则能写成"贴合人设的一律走这里"。
58
+ //
59
+ // 「何时不用」那一段保留(2026-09-17,用户采纳的四条里的第 6 条):参照 Claude Code 的
60
+ // Agent 工具(`AgentTool/prompt.ts:232-240`),把"不该用"写成**带替代工具**的具体清单
61
+ // (具体路径→Read;找定义→Grep/Glob),比笼统说"这个很贵"有用得多。
62
+ description: 'Run a named persona as a subagent: it gets the persona as its own system prompt, works on `task`, and returns only its final output.\n\nTwo modes: by default the child starts fresh — it cannot see this conversation, so `task` must be self-contained. With `inherit: true` the child is seeded with this conversation\'s finished turns (the same mechanism as the host\'s `subagent_fork`), so `task` only states what is new — use it for follow-ups on work already completed. Only **finished** turns are inherited: a delegation made during the current turn inherits nothing from that turn, so a mid-turn hand-off still needs a self-contained `task`.\n\nWrite `task` as the goal plus the context it needs — do not prescribe method or output format: those belong to the persona.\n\nWhen to use: work that matches one of the personas in the「可委派的子智能体」system-reminder injected into your context (or from subagent_manager_list) — a review, a focused investigation, a piece of writing — where the detail does not belong in your own context. Work that matches a persona belongs here, not in the host\'s `subagent` / `subagent_fork`: those take no persona. Use them only when no persona fits, or when you need a background run (this tool waits for the result).\n\nWhen NOT to use: reading a specific file (use Read), finding a definition (use Grep/Glob), or touching two or three files (use Read directly).',
46
63
  parameters: {
47
64
  agent: { type: 'string', required: true, description: 'Persona name from subagent_manager_list.' },
48
- task: { type: 'string', required: true, description: 'Self-contained task, with all context the subagent needs.' },
65
+ task: { type: 'string', required: true, description: 'The task for the subagent: the goal plus the context it needs. Self-contained by default; with inherit: true it only needs to state what is new. Leave method and output format to the persona.' },
66
+ inherit: { type: 'boolean', description: 'Let the subagent inherit this conversation\'s finished turns, like the host\'s subagent_fork (default false = a fresh child that cannot see this conversation). Only finished turns are inherited — a delegation made mid-turn cannot pass the current turn\'s content, so write `task` as if it were self-contained.' },
49
67
  },
50
68
  output: {
51
69
  schema: { type: 'string' },
@@ -54,6 +72,7 @@ export function defineSubagentManagerRunTool(subagents) {
54
72
  async execute(args, exec) {
55
73
  const name = String((args && args.agent) || '').trim();
56
74
  const task = String((args && args.task) || '').trim();
75
+ const inherit = args && args.inherit === true;
57
76
  if (!task)
58
77
  throw new Error('task 不能为空');
59
78
  // 官方 SubagentStartRequest.parent 必填:调用方 agent 缺失时给结构化错误,不把 undefined 透传下去。
@@ -66,12 +85,18 @@ export function defineSubagentManagerRunTool(subagents) {
66
85
  const available = allowed.map((p) => p.name).join('、') || '(无)';
67
86
  throw new Error('人设不可用: ' + name + (reason ? '(' + reason + ')' : '(可用: ' + available + ')'));
68
87
  }
88
+ // 这里**刻意没有**"深度不够就拒绝"的检查(2026-09-17 用户裁定,方案 A)。
89
+ // 它此前基于一个错误假设:以为传 `maxDepth: 1` 会让子会话再委派必然失败。实际上官方
90
+ // `dsh-tool-subagent` 的默认是 **3**、provider 只在**传了值**时才校验,所以子代理本来
91
+ // 就能继续嵌套。那个检查的唯一净效果是让**我们的**工具比官方严 —— 同一个子会话里官方
92
+ // 工具能委派、我们的被自己拦下 —— 而用户真想禁止嵌套也禁止不了(官方工具照样能)。
93
+ // `catalogDepth` 现在只管"目录注入到哪些会话",不参与委派可行性判断。
69
94
  // 按当前会话的 Agent 预设算工具限制:判断不了当前预设、或名单里的工具已经不存在时,
70
95
  // 结论里会带一句实话,跟着结果一起返回——不静默改变限制的强度。
71
96
  const decision = typeof subagents.toolFilterFor === 'function'
72
97
  ? await subagents.toolFilterFor(persona, exec.agent && exec.agent.ctx)
73
98
  : undefined;
74
- const r = await subagents.runSerial(exec.agent, persona, task, exec.signal, decision === undefined ? undefined : decision.filter);
99
+ const r = await subagents.runSerial(exec.agent, persona, task, exec.signal, decision === undefined ? undefined : decision.filter, inherit);
75
100
  const prefix = r.stopReason && r.stopReason !== 'completed' ? `[stopReason: ${r.stopReason}]\n` : '';
76
101
  const note = decision && decision.note ? `⚠ ${decision.note}\n\n` : '';
77
102
  return note + prefix + r.text;